How to implement Redux in a clean architecture

juanoa

Juan Otálora

Posted on June 2, 2023

How to implement Redux in a clean architecture

A note before start. State management in React is one of the most complex topics I have work so far. Different functionalities require different state management systems. Once this series is finished, we will have a more serious discussion about state management. 🙂

In previous articles, we have talked about what clean architecture is and how to organize the folder structure of a project using hexagonal architecture. Today, we are going to dive a little deeper into the implementation of Redux, one of the most recognized state management libraries in React. Despite facing increasing competition, Redux remains a good option for handling complex state management.

Redux meme

I won't go into detail about whether Redux is a good choice or not. My opinion remains positive when it is implemented in applications that require complex state management. For other cases, alternatives like React Query or SWR tend to be a better option. But as with everything in life, it depends. In future articles, and if this series receives a lot of support (❤️), we can try to implement a clean architecture using one of these alternatives.

What modifies the Redux state?

Once we have set up Redux and have all the components reading from the store, in my opinion, the most important decision remains: What modifies the store? What dispatch de action?

In my opinion, considering the architecture I'm presenting in this series of articles, Redux should behave like a database. In other words, its operation should not be tightly coupled with the functional logic of the application. For this reason, in my view, each reducer should have only two operations: "save" and "delete" (where "save" means saving to the store or updating if it already exists). This way, we transform our store into a collection of objects that can be added or removed based on our needs.

All the examples of Redux implementation code were developed using Redux Toolkit.



export const tasksSlice = createSlice({
  name: "tasks",
  initialState: [] as Array<Task>,
  reducers: {
    addTasksToStore: (state, action: PayloadAction<Array<Task>>) => {
      pushIntoReduxStateOrUpdateIt(state, action.payload);
    },
    deleteTasksFromStore: (state, action: PayloadAction<Array<Task["id"]>>) => {
      const taskIds: Array<Task["id"]> = action.payload;
      return state.filter((prevTask) => !taskIds.includes(prevTask.id));
    },
  },
});

// Implementation of pushIntoReduxStateOrUpdateIt
// If the object exists, update it, if not, push it in the state

interface ObjectWithId {
  id: string;
  [key: string]: unknown;
}

export const pushIntoReduxStateOrUpdateIt = (
  state: WritableDraft<ObjectWithId>[],
  elements: Array<ObjectWithId>
) => {
  for (const element of elements) {
    const index = state.findIndex((prevElement) => prevElement.id === element.id);
    if (index === -1) {
      state.push(element);
    } else {
      state[index] = element;
    }
  }
};


Enter fullscreen mode Exit fullscreen mode

In the traditional method of using Redux, each action corresponded to a use case, tightly coupling our application to the state management library and making it very difficult to switch to another one when the project's needs change.

Once we have set up our reducers, we need to revisit the million-dollar question: What modifies the Redux state? In this case, I think that the repository implementation should modify it, meaning we need to abstract the use case from the existence of Redux. This offers several advantages:

  • If in the future we want to change Redux to another state management library or eliminate it altogether, it will be relatively straightforward as we would only need to modify the repository implementations.
  • It is less error-prone by having all state mutation logic in the repository, abstracting this concept from the use cases and the domain.

Redux as an infrastructure component

Now that we have decided to move the dispatch invocation to the infrastructure layer, let's see an example of a repository implementation:

For instance, the implementation of a "get" or "find" retrieves the information from the REST API and stores it in Redux so that all connected components can consume it. Additionally, to allow more flexibility, we also return the data in case we want to use it in the use case or in the component.



  export const getAll = async (): Promise<Array<Task>> => {
    const taskDtos = await tasksApiRestClient.getAll();
    const tasks = taskDtos.map(mapTaskFromApiRest);

    store.dispatch(addTasksToStore(tasks));
    return tasks;
  }


Enter fullscreen mode Exit fullscreen mode

For "create" or "update," the process is quite similar. In this case, we make the API call, and if the promise resolves successfully, we update the state by invoking the dispatch. Using this approach, you can implement optimistic updates easily.



  export const save = async (task: Task): Promise<void> => {
    // Another option could be update the store before invoke the API (optimistic update)
    store.dispatch(addTasksToStore([task]));

    const taskDto = mapTaskToApiRest(task);
    await tasksApiRestClient.save([taskDto]);
  }


Enter fullscreen mode Exit fullscreen mode

Lastly, in the case of "delete," it is quite similar to the previous examples, with the difference that once the call is resolved, we need to remove the data from the store instead of updating or creating it.



  export const delete = async (taskIds: Array<Task["id"]>): Promise<void> => {
    store.dispatch(deleteTasksFromStore(taskIds));

    await Promise.all(taskIds.map(tasksApiRestClient.deleteById));
  },


Enter fullscreen mode Exit fullscreen mode

This way, as you can see, we are updating the state at the same time we make the request to the server, preventing the front-end or back-end from becoming out of sync at any point. Furthermore, by decoupling Redux actions from functional logic and moving this logic to use cases, changing the state management library is as simple as changing the repository implementation.

In the next article (I'll try not to take as long as this time 🙄), we will talk about dependency injection with React contexts, creating a wrapper that we could take to any application developed with another framework or JS library, and our application would continue to function.

💖 💪 🙅 🚩
juanoa
Juan Otálora

Posted on June 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related