A Redux implementation with the new kid on the block: Flutter

Jakub Orlinski Jakub Orlinski
06 - 10 - 2020

A while ago my colleague, Victor, took an initial look at Flutter, a new framework for mobile development from Google. At the time Flutter was still in its early stages, the first beta having just been released. Here, we will look into how to extend an app with a Redux store, Thunk future actions and the Redux Persistor.

What is Redux?

Redux is a container for a store of information for applications. It started with Redux for the React.js framework, where it alleviated a lot of the issues with passing information around the app tree. In React, just like in Flutter, an application is composed of components (in Flutter called Widgets), which are related to each other in a parent-child structure. The data flow is then top-down: the parent provides its children with prop(ertie)s and each component can have its own state.

For small applications this is not a big problem - the data can be centralised at the root node, or just passed around with props. However, with any application that has any degree of complexity, this gets rather tricky. Take as an example, an online shopping app and its integral part - the shopping cart. The cart has to follow the user around any screen that they might navigate to, and sometimes even share its information with other components like a checkout page. Passing that data around, with easy access to its modification would be a lot of work. And here comes in Redux - a single source of truth that is accessible from any component within the app, no matter where it is in the tree.

A Redux store is therefore mostly just an object that holds information about the state of the app and handles its delivery and changes. Any component connected to the store can emit Actions - think API calls or reactions to user input - that take a copy of the current state and modify it. That new state is then committed to the store through the Reducers. After the commit, the changes are visible to every component that relies on them, triggering a re-rendering to reflect the shift in state.

Redux store

In this example, we will use the structure supplied by Android Studio when making a new project. The app will have a list of movies in its store, allowing any component to connect to that piece of data, display it and modify it.

First let us make the Movie object. It will have some basic properties such as title, year of release and so on.

                                    class Movie {
  final String title;
  final int year;
  final String image;
  final double rating;

  Movie(this.title, this.year, this.image, this.rating);
}
                                

With the movie class defined, we can make the Redux store that holds a movie list and the currently active movie.

                                        class AppState {
  final List movieList;
  final Movie currentMovie;

  AppState({
    this.movieList,
    this.currentMovie,
  });

  factory AppState.initial({movieList = const [], currentMovie}) => AppState(
    movieList: movieList,
    currentMovie: currentMovie,
  );

  AppState copyWith({
    List movieList,
    Movie currentMovie,
  }) {
    return AppState(
      movieList: movieList ?? this.movieList,
      currentMovie: currentMovie ?? this.currentMovie,
    );
  }
}
                                    

Then let us make an action that adds a new movie to the store.

                                    class MovieSetSuccessAction {
  Movie currentMovie;

  MovieSetSuccessAction(payload) {
    this.currentMovie = payload;
  }
}

MovieSetSuccessAction setMovieAction(Movie movie) {
  return new MovieSetSuccessAction(movie);
}
                                

And reducer that then handles such a change to the store.

                                        AppState _movieSetSuccessReducer(AppState state, MovieSetSuccessAction action) {
  return state.copyWith(currentMovie: action.currentMovie);
}

final movieReducer = combineReducers<AppState>([
  TypedReducer<AppState, MovieSetSuccessAction>(_movieSetSuccessReducer),
]);
                                    

To connect a component, such as the text in the main page, to the store we will construct a ViewModel. It allows us to specify exactly which parts of the store the component uses, so that changes unrelated to it, do not cause it to re-render.

                                    class _MyHomePageViewModel {
  final Movie currentMovie;

  _MyHomePageViewModel({
    this.currentMovie
  });

  static _MyHomePageViewModel fromStore(Store<AppState> store) {
    return _MyHomePageViewModel(
      currentMovie: store.state.currentMovie,
    );
  }
}
                                

With all the parts of the store ready, we can now add the store to our main app:

                                        void main() {
  final Store<AppState> _store = new Store<AppState>(
    movieReducer,
    initialState: AppState.initial(),
  );

  runApp(MyApp(
    store: _store,
  ));
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: new StoreConnector<AppState, _MyHomePageViewModel>(
          converter: (store) => _MyHomePageViewModel.fromStore(store),
          builder: (context, viewModel) {
            return Column(
              children: [
                Text(viewModel.currentMovie != null ? viewModel.currentMovie.title : "No movie selected"),
              ],
            );
          },
        ),
      ),
    );
  }
}
                                    

We will also connect the FloatingButton to the store:

                                    floatingActionButton: new StoreConnector<AppState, VoidCallback>( // <- this can be added to the Scaffold of the main app
        converter: (store) {return () => store.dispatch(setMovieAction(new Movie("A nice movie", 2020, "img/poster.jpg", 0.0)));},
        builder: (context, setMovieFunc) {
          return new FloatingActionButton(
            onPressed: setMovieFunc,
            tooltip: 'Set movie',
            child: new Icon(Icons.movie),
          );
        },
      ),
                                

ReduxThunk

When dispatching actions, a lot of times we want to use an asynchronous function, such as a call to an API. As the basic implementation of Redux does not support async actions, we require the Thunk middleware for that.

Firstly we will change the main() method to introduce the middleware to the store:

                                        void main() {
  final Store<AppState> _store = new Store<AppState>(
    movieReducer,
    initialState: AppState.initial(),
    middleware: [thunkMiddleware, ] // <- add the middleware
  );

  runApp(MyApp(
    store: _store,
  ));
}
                                    

When fetching data from an API, the most used type of encoding is a JSON object. We therefore need to extend the Movie model to allow for serializing to and from JSON

                                    class Movie {
  final String title;
  final int year;
  final String image;
  final double rating;

  Movie(this.title, this.year, this.image, this.rating);

  static Movie fromJson(Map<String, dynamic> json) { // <- convert from a JSON object to a Movie
    if (json != null && json.length > 0) {
      return new Movie(json['title'], int.parse(json['year']), json['image'], double.parse(json['imDbRating']));
    } else {
      return null;
    }
  }

  dynamic toJson() { // <- convert from a Movie to a JSON object
    return {
      "title": this.title,
      "year": this.year.toString(),
      "image": this.image,
      "imDbRating": this.rating.toString(),
    };
  }
}
                                

Then we can make an asynchronous action that, say, fetches a list of movies from the API

                                        ThunkAction fetchMoviesRequest() {
  return (Store store) async {
      List<Movie> result = [];

      apiClient.apiCall(API_GET_TOP_MOVIES)
          .then((res) {
        for (dynamic item in res['items']) {
          result.add(Movie.fromJson(item));
        }
        store.dispatch(
            new MoviesFetchSuccessAction(result));
      });
  };
}

class MoviesFetchSuccessAction {
  List<Movie> movieList;

  MoviesFetchSuccessAction(payload) {
    this.movieList = payload;
  }
}
                                    

And extend the reducer to allow for this action

                                    final movieReducer = combineReducers<AppState>([
  // (...)
  TypedReducer<AppState, MoviesFetchSuccessAction>(_moviesFetchSuccessReducer),
]);
AppState _moviesFetchSuccessReducer(AppState state, MoviesFetchSuccessAction action) {
  return state.copyWith(movieList: action.movieList);
}
                                

We will also extend the _MyHomePageViewModel to provide the dispatch function and the movie list to the text in the home page

                                        class _MyHomePageViewModel {
  final List movieList;
  final Movie currentMovie;

  final Function() getMovies;                   // <- declare the function

  _MyHomePageViewModel({
    this.movieList,
    this.currentMovie,
    this.getMovies
  });

  static _MyHomePageViewModel fromStore(Store<AppState> store) {
    return _MyHomePageViewModel(
      movieList: store.state.movieList,
      currentMovie: store.state.currentMovie,
      getMovies: () {                            // <- and define it
        store.dispatch(fetchMoviesRequest());
      },
    );
  }
}
                                    

And then use those hooks to display the data

                                    class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: new StoreConnector<AppState, _MyHomePageViewModel>(
          converter: (store) => _MyHomePageViewModel.fromStore(store),
          builder: (context, viewModel) {
            return Column(
              children: [
                Text("Number of movies in list ${viewModel.movieList.length}"), // <- display the number of movies in the list
                MaterialButton(onPressed: viewModel.getMovies, child: Text("Fetch movies")), // <- add a button for dispatching the fetch
                Text(viewModel.currentMovie != null ? viewModel.currentMovie.title : "No movie selected"),
              ],
            );
          },
        ),
      ),
    );
  }
}
                                

ReduxPersist

To make sure that the state can persist between user interactions for a smoother user experience, one might want to introduce the Redux Persist package to the fold. It allows us to save the state of the store as a JSON object in a couple different ways. The easiest is to save it locally to file, but it can also be sent to some backend service that allows you to restore the state across devices and to even integrate it with a Redux enabled web app. However, while it does provide some useful functionality, it requires the addition of JSON serialisability to the store, which can be a bit of a hassle.

Firstly, add the persistence functionality to the store and load a state in if its available

                                        Future<File> get _localFile async {
  final path = await getApplicationDocumentsDirectory();
  return File('$path/AppState1.json');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized(); // <- this is necessary to have bindings to access local files

  final persistor = Persistor<AppState>( // <- define the persistor
    storage: FileStorage(await _localFile),
    serializer: JsonSerializer<AppState>(AppState.fromJson),
  );

  AppState initialState = await persistor.load(); // <- this loads the data from the file into the store

  final Store<AppState> _store = new Store<AppState>(
      movieReducer,
      initialState: initialState ?? AppState.initial(),  // <- make sure to use the initial state 
      middleware: [thunkMiddleware, persistor.createMiddleware()]  // <- don't forget the middleware!
  );

  runApp(MyApp(
    store: _store,
  ));
}
                                    

And add the JSON serialize methods to the Store

                                    class AppState {
  final List movieList;
  final Movie currentMovie;

  AppState({
    this.movieList,
    this.currentMovie,
  });

  factory AppState.initial({movieList = const [], currentMovie}) => AppState(
    movieList: movieList,
    currentMovie: currentMovie,
  );

  static AppState fromJson(dynamic json) { // <- convert from a JSON object to an AppState
    if (json == null) {
      return AppState.initial(movieList: [], currentMovie: null);
    } else {
      return AppState.initial(movieList: parseList(json, 'movieList'), currentMovie: Movie.fromJson(json["currentMovie"]));
    }
  }

  dynamic toJson() { // <- convert from AppState to JSON
    List movieList = this.movieList.map((movie) => movie.toJson()).toList();
    dynamic currentMovie = this.currentMovie != null ? this.currentMovie.toJson() : null;

    return {
      'movieList': movieList,
      "currentMovie": currentMovie
    };
  }

  AppState copyWith({
    List movieList,
    Movie currentMovie,
  }) {
    return AppState(
      movieList: movieList ?? this.movieList,
      currentMovie: currentMovie ?? this.currentMovie,
    );
  }
}

List<Movie> parseList(dynamic json, String key) { // <- helper function to deal with lists
  List<Movie> list = [];
  json[key].forEach((item) {
    list.add(Movie.fromJson(item));
  });
  return list;
}
                                

Conclusion

Redux is a very useful tool for developing apps of significant size or complexity. It centralizes the process of accessing and modifying data, making it easier to get what information you need, wherever you need it. It is not necessary for every app out there - the additional costs of setting it up are quite high. However, it forces you into following its paradigm, which standardises the flow of data and enforces a neat separation of code.

The additions on top of Redux make it even better. ReduxThunk allows you do make the actions asynchronous, facilitating delayed responses or API calls without much work. There is no reason not to add it to any Redux project, as all you need is just the middleware declaration and you're good to go!

ReduxPersist introduces more complexity to the application, however the gains to fluidity from having a locally stored state of the app available no matter the network are quite substantial. It is by no means a necessary addition, but in certain circumstances or in any cross-platform application, it could prove extremely useful.

Jakub Orlinski. Jakub is a front-end developer at El Niño and became quite a big fan of Docker two years ago but, sadly, never got round to part two of his blog. Jakub Orlinski. Jakub is a front-end developer at El Niño and became quite a big fan of Docker two years ago but, sadly, never got round to part two of his blog.

Deel deze blog

Jouw partner voor digitalisering  en  automatisering

Bedankt voor je bericht! We nemen z.s.m. contact met je op. :)
We willen graag je naam en e-mailadres weten om contact op te kunnen nemen.

Laten we samen een keer brainstormen of vertel ons wat over jouw uitdaging. Laat je gegevens achter en we nemen z.s.m. contact met je op.