Contents

    Redux and Elementary: How to Make Them Click

    One of the main questions that come up when designing an app is what state manager to choose. It has to be implemented in the way that:

    • Helps separate business logic from presentation logic.
    • Has fail-safe code.
    • Expand the functionality of your project in a clear and simple way when new features are added.

    In this article, we’ll talk about managing the global state. Hi! My name is Vladimir Deyev, and I’m a Flutter developer at Surf. I’ll tell you about the most efficient way to use Redux in conjunction with Elementary and pair Redux with asynchronous operations.

    Redux + Elementary: how should an app work?

    Let’s make it simple: we’re not going to dive deep into data structure and the beauty of what we display on the screen. Instead, we’ll take a closer look at implementing Redux and Elementary and making them work as a team. 

    Imagine the most primitive app: 

    • If you tap the “plus” button, the app loads and displays a random photo of a dog. 
    • If you restart the app, the loaded data stays on the screen — nothing needs to be downloaded. 
    • If you tap the “X”, all photos must be deleted.

    This app on Github

    Let’s see how the app works. State stores app data. State is immutable and we can only create a new State.

    • Action is a trigger that changes the state. 
    • Redux can’t handle asynchronous behavior as is. And since we have network requests that we need to process in Redux itself, we’ll be using redux_epics as middleware. Epics middleware is a mediator between reducer and action. It consumes action, processes network requests and runs the next action.
    • Reducer is a “control center” of the Redux architecture. It accepts actions directly from the middleware or the app and handles state by creating a new state with new data.

    Let’s add the key dependencies:

    • Elementary
    • Redux
    • Redux_epics

    We’ll need that to implement the task.

    What data do we need? We’ll use https://dog.ceo/dog-api/ as a data source. As you can see in the documents, JSON with a server response contains a message, which, in turn, contains the url of a dog image and a status field. So, let’s define two classes: we’ll be using DogData to store data and DogDTO to exchange data between the data layer and the network layer.

    @freezed
    class DogData with _$DogData {
     const factory DogData({
       required final String message,
       required final String status,
     }) = _DogData;
    }
    
    @JsonSerializable(createToJson: false, checked: true)
    class DogDTO {
     final String message;
     final String status;
     
     const DogDTO(
       this.message,
       this.status,
     );
     
     factory DogDTO.fromJson(Map<String, dynamic> json) => _$DogDTOFromJson(json);
     
     DogData toModel() => DogData(
           message: message,
           status: status,
         );
    }

    Let’s move on to the Redux part. First, define the state, the data storage.

    @freezed
    class DogsState with _$DogsState {
     const factory DogsState({
       @Default(IListConst<DogData>([])) IList<DogData> dogsList,
       @Default(null) DioError? error,
     }) = _DogsState;
    }

    As you can see, the data is stored as an immutable list with a clean list as a default value. We’ll also use it to store data about data request errors.

    Let’s implement the first action to load the network data. We’ll be creating a class called RequestLoadingAction with a mixin.

    class RequestLoadingAction with ActionCompleterMixin {
     RequestLoadingAction();
    }
    mixin ActionCompleterMixin {
     final _completer = Completer<void>();
     
     void complete() {
       if (!_completer.isCompleted) {
         _completer.complete();
       }
     }
     
     void completeError(Object error, [StackTrace? stackTrace]) {
       if (!_completer.isCompleted) {
         _completer.completeError(
           error,
           stackTrace,
         );
       }
     }
     
     Future<void> get future => _completer.future;
    }

    To keep track of network requests, we’ll be using completer as a system of signals for the Elementary part. To avoid writing over and over about processing completer in each action class, according to which the network request will be processed, let’s put this piece of code in the mixin. By the way, this crafty little fella will lift some of our burden when we write tests for the Redux part of the app, and we won’t even have to mock a completer.

    Let’s now write the middleware with Epics:

    class DogDataEpicMiddleware {
     final Client _client;
     final SharedPrefHelper _sharedPrefHelper;
     
     const DogDataEpicMiddleware(
       this._client,
       this._sharedPrefHelper,
     );
     
    Epic<DogsState> getEffects() => combineEpics([
       TypedEpic<DogsState, RequestLoadingAction>(_onLoadingCharacter),
              ]);
     
    Stream<Object> _onLoadingCharacter(
             Stream<RequestLoadingAction> action, EpicStore<DogsState> _) =>
         action.asyncExpand((action) async* {
           try {
     
            final response = await _client.getDog();
             if (response != null) {
               final listFromSP = await _sharedPrefHelper.get('links');
               var newList = <String>[];
               if (listFromSP != null) {
                 newList = [...listFromSP];
               }
     
               newList.add(response.message);
    await _sharedPrefHelper.set('links', newList);
               action.complete();
     
               yield AddingDataAction(response.toModel());
             }
           } on DioError catch (err) {
             action.completeError(err);
             yield CatchingErrorAction(err);
           }
         });
     
     
    }

    Let’s transform Stream from the incoming action into Stream from the outgoing action:

    • either into an error action, to which we pass dio error,
    • or into an adding data action, to which we pass the object received from the network. 

    To avoid writing one monstrous Epic, where all the actions incoming to the middleware are processed, let’s use combineEpics. We’ll have all the Epics stored in a list: small units, easy to test, each tied to a specific action. We’ll also use this place to save the list of picture data from the local storage and finish the completer.

    Remember to add a new action, which will be processed in the reducer.

    class AddingDataAction {
     final DogData newDog;
     
     const AddingDataAction(this.newDog);
    }

    And an error action.

    class CatchingErrorAction {
     final DioError error;
     
     const CatchingErrorAction(this.error);
    }

    Handling State in Reducers. The inner sanctum of Redux. 

    class DogDataReducers {
     static final Reducer<DogsState> getReducers = combineReducers([
       TypedReducer<DogsState, AddingDataAction>(_onAddingAction),
       TypedReducer<DogsState, CatchingErrorAction>(_onError),
       ]);
     
    static DogsState _onAddingAction(DogsState state, AddingDataAction action) {
       final dogsList = state.dogsList.add(action.newDog);
       return state.copyWith(dogsList: dogsList);
     }
     
     static DogsState _onError(DogsState state, CatchingErrorAction action) {
       return state.copyWith(error: action.error);
     }
     
    }

    That’s pretty simple: if we get an error, return a new state with the current error in it. If we get new data, return the state with the new data.

    There’s only one little thing left: letting the app know that there’s Redux with its own state, middleware, and reducers.

    Provider<Store<DogsState>>(
               create: (context) => Store<DogsState>(
                     combineReducers<DogsState>([
                       DogDataReducers.getReducers,
                     ]),
                     initialState: const DogsState(),
                     distinct: true,
                     middleware: [
                       EpicMiddleware(
                         DogDataEpicMiddleware(
                           Client(context.read<Dio>()),
                           context.read<SharedPrefHelper>(),
                         ).getEffects(),
                       )
                     ],
                   )),

    That’s it: Redux is in the app. Now, for the fun part — “asking” Elementary to work with it.

    We wrote about Elementary in more detail in another article.

    How do we tie Redux and Elementary together?

    Elementary consists of three layers: 

    • Model, 
    • WidgetModel, 
    • Widget. 

    Let’s take the Redux tool we’ve just built and enable it in the Model, going step by step. Then, we’ll pass the information we receive from Redux through WidgetModel and to the presentation layer and make the same wheels go backwards: Widget to Model.

    At initialization, we need to add a subscription to state changes in the Model.

    final Store<DogsState> _store;
    final _dogsList = ValueNotifier<IList<DogData>?>(null);
    late final StreamSubscription<DogsState> _storeSubscription;
     
    @override
     void init() {
       super.init();
     
       _dogsList.value = _store.state.dogsList;
     
       _storeSubscription = _store.onChange.listen(_storeListener);
     }
     
    void _storeListener(DogsState store) {
       _dogsList.value = store.dogsList; 
       final error = store.error;
     
       if (error != null) {
         handleError(error);
       }
     }

    Now we can track changes in the list of data and errors occurring in network requests. Any of these events can be processed any way we want. 

    To make Redux react to changes in the UI, all we have to do is call dispatch in the Model method and pass it a corresponding action:

    Future<void> fetchDog() async {
       final action = RequestLoadingAction();
     
       _store.dispatch(action);
     
       return await action.future;
     }

    The interactions between the state manager and the UI are enabled: 

    • A user taps a button on the screen.
    • Interaction with Redux is launched through the screen-WidgetModel-model bundle: data from the network are loaded to the middleware. Then, via action, they are passed to reducers, which create new states with new data.
    • Model is subscribed to changes in state and is notified. The new data are entered in the ValueNotifier, the changes in which pass through the WidgetModel and are listened to on the screen.

    Pros and cons of Redux + Elementary

    Pros:

    • Thanks to Redux and Elementary working in conjunction, we only manage the rebuild of the elements we need.
    • Redux state is the only source of truth. State is immutable: the current state of data can only be copied. Therefore, you can avoid any unplanned changes in the current data. Thanks to the architecture, you can easily trace which actions taken on the data lead to which results.
    • Once you start developing new features, you can easily add the fields you need to state as well as the actions you need or process them with a specific reducer.
    • All things data loading and processing are managed by Redux.  

    Cons:

    • A massive amount of boilerplate code even for one state.
    • If your app is supposed to have several independent data sources, writing several redux states, reducers and a great number of actions will be problematic.

    Links: