Simplify state with reducer

thexdev

M. Akbar Nugroho

Posted on March 8, 2023

Simplify state with reducer

TL;DR

React state is good to store a reactive data. When the data changes, the UI will re-render and shwoing the latest update.

Complex UI also need more than one state. Possibly more than three.

To simplify manage the state, instead of updating the state one by one using its setter function, it's better to put all states in one place and update it according to event occured using dispatch() function.



// Example of end result

// From this
setLoading(true);
setVisible(true);
setStatus('error');

// To this
dispatch({ type: 'loading_started' });
dispatch({ type: 'status_changed', payload: 'success' });
dispatch({ type: 'visible_toggled' });


Enter fullscreen mode Exit fullscreen mode

React reducer API

Introduction

We love state because it makes our apps interactive with less of code, but managing lots of state can be tricky.

Imagine we have a form component. It's very common case that a form can have mutiple state inside of it. For example, a form may have states for loading, status, form submit, etc.

The reason why we do this because we want to tell the users what happen to the current process.

With so many states it is a good advice if you put all states inside one place. A large, maintainable and consistent shape.

And now, we are going to explore one of built-in React API to create our own shape to simplify managing the states.

The Distraction

A form commonly use an alert component to inform the users that their data is successfully processed or not.

A form state changed to success
An alert component appears with different style according to the status state.

...and your code basically do something like this:



setStatus('success');


Enter fullscreen mode Exit fullscreen mode

Theres nothing wrong with that code above. But, could you imagine managing many states inside a component? BTW, let's add more complexity to the UI.

Loading changed to false

Here's what the changes does. When user clicks the submit button, the button text change to "Loading...". Indicating that the data is being processed. The button is disabled to prevent user clicks multiple times until the process done.


So far we have two states. The status and loading. Can you spot the "distraction"? I think not yet.

Some particular apps using an overlay to display the form to minimize the page transition which will improve UX. The form should appear when user click a "trigger" and can be closed.

User clicks Add Product button

Now if we combining all states we should have status, loading and visible.

Let's assume we have declared those states inside our component and to change the value for each state, you use the setter function.



setVisible(true);
setLoading(true);
setStatus('warning');


Enter fullscreen mode Exit fullscreen mode

As your UI getting more complex, you will have more states. More states meaning more setter functions and that's the first distraction.

With so many setter functions, the behaviour of consistency to update the state a little bit lost because to update a state you need a specific setter function.


If using multiple setter function in a single component isn't make you distracted, let's take a look to maintainability.

Imagine you develop a text editor for eCommerce website. So the seller can format the description part of a product to provide better information.

Text editor
Example of simple text editor

From image above we possibly have four states inside the text editor. Which is:

  • formatter
  • mode
  • showOptions
  • letterCount

Although this is very simple text editor, you need to extract the UI into components based on its functionality and this is the problem come from.



// Just example :)
function TextEditor() {
  return (
    <div>
      <Formatter />
      <ModeSwitch />
      <Options />
      <ContentBody >
      <LetterCount />
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Some of components need sharing states to act normally. For example the <Formatter /> may use the mode state because the formatter appear based on what current mode is being used. If user switch to Markdown, the formatter should disappear and vice versa.

The question is how do we share the states through all components?

The answer is very simple. Yes, using the Context. But, how? Like this?



function TextEditor() {
  // state declarations here

  return (
    <div>
      <TextEditorProvider
        formatter={formatter}
        mode={mode}
        showOptions={showOptions}
        letterCount={letterCount}
      >
        <Formatter />
        <ModeSwitch />
        <Options />
        <ContentBody >
        <LetterCount />
      </TextEditorProvider>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

And inside the <Formatter /> component, use the mode state.



function Formatter() {
  const state = useContext(TextEditorContext);

  const [mode] = state.mode;

  return mode === 'editor' <div /> : null;
}


Enter fullscreen mode Exit fullscreen mode

Another problem is the props of <TextEditorProvider /> grow exponentially as you add more state to the <TextEditor /> component.

So, do we have better solution? Well, of course, yes.

The Flux Architecture

We are not going to discuss Flux as a whole because it's a huge topic. What we're gonna do is to getting know about the main idea of Flux Architecture.

Flux Architecture or Flux is a pattern to manage states that live inside your component.

It's not a framework, but something like idealogy similar to MVC pattern. Even though it has minimal library to simplify our work.

Flux has four main parts:

  • Dispatcher
  • Stores
  • Actions
  • Views

Data in a Flux application flows in a single direction
https://facebook.github.io/flux/docs/in-depth-overview

Dispatcher

A dispatcher is a central place to updating the state. It is similar with setter function of React state.



const [state, setState] = useState();

// This is the dispatcher.
setState();


Enter fullscreen mode Exit fullscreen mode

Note:

That code above is not the actual flux dispatcher.

Stores

A store is what holds the data of an application. So, instead of expose the data publicly, it's wrap the data in the store and perform operation inside it (via dispatcher).

You can see here for an example.

Actions

An action is a simple object that describe of what condition or event that being occurred. Action is used by dispatcher to determine what operation needs to be performed.



const MODE_SWITCHED_TO_MARKDOWN = {
  type: 'mode-switched',
  payload: 'markdown'
};

const MODE_SWITCHED_TO_EDITOR = {
  type: 'mode-switched',
  payload: 'editor'
};

// Dispatch an action
dispatcher.dispatch(MODE_SWITCHED_TO_MARKDOWN);


Enter fullscreen mode Exit fullscreen mode

If an action need to pass a data or you want the action dynamic, you can use Action Creator. Action Creator is a simple function that produce an action.



const switchMode = (mode) => ({ type: 'mode_switched', payload: mode })

dispatcher.dispatch(switchMode('html'));


Enter fullscreen mode Exit fullscreen mode

We have to remember that the most important part is type because we need this information to identify what operation should be performed.


The why Flux pattern can provide a better solution rather than raw React state because with Flux we can do many things. Not only holds a data.

  • Separation of concerns
  • Easier Debugging
  • Code predictability

The list above is some of Flux benefits.

Views

Nothing special with views. Views is component you made that may use the store or dispatch an action.



// Example of component that dispatch an action
function ModeSwitch() {
  return <Switcher
    onSwitch={() => {
      dispatcher.dispatch(MODE_SWITCHED_TO_EDITOR);
    }}
  />
}


Enter fullscreen mode Exit fullscreen mode

See? Now it looks maintainable and consistent because we don't need to use specific setter function for each state.

You might be feel this approach little bit confused because I only show the main idea and use part of the code. But, once you got the main idea I ensure you will love this approach.

And the reason why I only show the main idea because Flux is currently in maintenance mode, but the idea still live and adopted by React.useReducer and Redux.

To get more information about Flux, go to their official website here.

Combining Context And Flux

Flux by defult is shareable between all views. That means we don't need to pass the Flux data into the Context.

Since Flux is in maintenance mode, it's not good practice to use Flux anymore. But, follow the main idea is a good deal, tho.

Note:
The maintenance mode is refers to Flux library not the main idea.


Is it possible to implement the Flux idea and share it between components through Context? The answer is yes.

Let's implement the Context first.



// text-editor-context.jsx
import React from 'react';

const TextEditorContext = React.createContext({});

export const TextEditorProvider = (props) => {
  <TextEditorContext.Provider value={{ store: props.store, dispatch: props.dispatch }}>
    {props.children}
  </TextEditorContext.Provider>
};

export default TextEditorContext;


Enter fullscreen mode Exit fullscreen mode

The Context role is act like a bridge that connects all components to consume shareable state. Just it.

So, how implement the Flux? since the library itself is not continued by the maintainer.

Therefore, please welcome...

React.useReducer

What is React.useReducer? Well, because you already know about Flux. It is simplified Flux without shareable ability. Therefore, we use Context to make it shareable.

React.useReducer or simplified called reducer is slightly different compared to Flux.

Flux is require you to create a class that extends ReduceStore from the Flux library. While React.useReducer is a hook and takes two arguments to operates. The reducer and inititalState.

Note:
Read more about React.useState here.

With React.useState we can create a Flux-like state and should answer the question from the previous section.



// Example of reducer.
const initialState = {
  formatter: null,
  mode: 'editor',
  showOptions: false,
  letterCount: 0,
};

// Actions
export const switchMode = (mode) => {
  return {
    type: 'mode-switched', 
    payload: mode,
  };
};
// Rest of actions...

const reducer = (state, action) => {
  switch (action.type) {
    case 'mode-switched':
      return { ...state, mode: action.payload }
    case 'formatter-changed':
      return { ...state, formatter: action.payload };
    case 'options-toggled':
      return { ...state, showOptions: !state.showOptions };
    case 'letter-count-increased':
      return { ...state, letterCount: state.letterCount + 1};
    case 'letter-count-decreased':
      return { ...state, letterCount: state.letterCount - 1};
    default:
      throw new TypeError('Unidentified Type Given.');
  }
}

function TextEditor() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <TextEditorProvider state={state} dispatch={dispatch}>
        <Formatter />
        <ModeSwitch />
        <Options />
        <ContentBody >
        <LetterCount />
      </TextEditorProvider>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

And now all components wrapped inside <TextEditorProvider /> should be able to access the state object and dispatch API.

The Final Result

After all, we are going to combine all solutions.

The inconsistency problem has been solved from code above. Theres no specific setter function. Theres only dispatch() function to update specified state.

The maintainability problem is also solved because if you want add new feature or states, you are not polluting the <TextEditorProvider />.

Instead of adding props, you just need to update the initialState and adding some actions or action creators.

And now you can use the states across all components...



function ModeSwitch() {
  const { dispatch } = useContext(TextEditorContext);

  return <Switcher
    onSwitch={() => {
      dispatch(switchMode('markdown'));
    }}
  />
}


Enter fullscreen mode Exit fullscreen mode


// Theres no need to destruct the array :)
function Formatter() {
  const { state } = useContext(TextEditorContext);

  return state.mode === 'editor' <div /> : null;
}


Enter fullscreen mode Exit fullscreen mode

You can even make the context simpler using a custom hook.



const useStore = () => {
const store = useContext(TextEditorContext);

return store;
};

// Use it like this
const { state, dispatch } = useStore();

Enter fullscreen mode Exit fullscreen mode




Conclusion

Managing state can be tricky for complex application. Using combination of state and context is good, but we have better solution by using reducer.

Reducer is ensure us to update the state only using the dispatch() API. Minimizing distraction when using setter function, improve maintainability and consistency.

The Flux pattern is every useful for large application. Separation of concern, easy to debug and predictability. All of those features are needed by long-runnung application.

Luckly, reducer is already implement this pattern. Even though with simplification, but it still worth.

By combining context and reducer, you got Flux with simpler approach. Awesome!

FIY, one of top library that use Flux idea is Redux. And this is my favorite React state management.

💖 💪 🙅 🚩
thexdev
M. Akbar Nugroho

Posted on March 8, 2023

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

Sign up to receive the latest update from our blog.

Related