loading...
Cover image for Enhancing useReducer.

Enhancing useReducer.

ipshot profile image Roman Zhernosek ・4 min read

I really like React built-in useReducer and useContext hooks. They made an app state management handy and using Redux lost any sense for me.

When I used them first time I realized that there is a shortage of some pretty useful Redux features:

  • useSelector. You can't optimize re-renders just with memo while using useContext inside.
  • Global dispatch. You have to use multiple dispatches because every useReducer has its own dispatch.
  • Cache. You have to have a special place for caching reducers data.

So I decided just to add this 3 features around these hooks.
And this idea turns to a new tiny library I called Flex Reducer which seems pretty handy (at least for me).

Interesting fact!
Flex Reducer uses neither useReducer nor useContext in its implementation.

Let's write a typical Todo app in 2 versions - one with built-in useReducer + useContext and another with Flex Reducer to demonstrate its handy.

First create a root file where we render our React tree to DOM. It will be the same for both versions.

// index.js
import TodoApp from "./TodoApp";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <TodoApp />,
  rootElement
);

Note: We don't have to combine reducers, create store and use Provider anymore. Yippee! :)

Now let's create a main Todo app component using built-in useReducer.

// TodoApp.js
import { useReducer, createContext, useMemo } from 'react';
import AddTodo from './AddTodo';
import TodoList from './TodoList';

export const AppContext = createContext(null);
const cache = {};

export default function TodoApp() {
  const [state, dispatch] = useReducer(reducer, cache.state || initialState);
  cache.state = state;
  const actions = useMemo(() => ({
    setInput: (value) => {
      dispatch({
        type: 'SET_INPUT', 
        payload: value
      })
    },
    addTodo: ({ id, content }) => {
      dispatch({
        type: 'ADD_TODO',
        payload: { id, content }
      })
    }
  }), []);
  return (
    <AppContext.Provider value=[state, actions]>
      <div className="todo-app">
        <h1>{state.title}</h1>
        <input value={state.input} onChange={e => actions.setInput(e.target.value)} />
        <AddTodo />
        <TodoList />
      </div>
    </AppContext>
  );
}

Good enough. Let's see how it looks if we use Flex Reducer.

// TodoApp.js
import { useFlexReducer, dispatch } from 'flex-reducer';
import AddTodo from './AddTodo';
import TodoList from './TodoList';

export const setInput = (value) => dispatch({
  type: SET_INPUT,
  payload: value
});
export const addTodo = ({ id, content }) => dispatch({
  type: ADD_TODO,
  payload: { id, content }
});

export default function TodoApp() {
  const [state] = useFlexReducer('app', reducer, initialState);
  return (
    <div className="todo-app">
      <h1>{state.title}</h1>
      <input value={state.input} onChange={e => setInput(e.target.value)} />
      <AddTodo />
      <TodoList />
    </div>
  );
}

Looks better and feel readability was improved.
We got next improvements:

  • No need to use React Context.
  • We don't have to care about cache.
  • We are able to move actions anywhere as we have a global dispatch.

Now let's compare a re-render optimization for Add Todo button.
With React Hooks.

// AddTodo.js
import { useContext, memo } from 'react';
import { appContext } from './TodoApp';

const genId = () => Math.rand();

const AddTodo = memo(({ input, actions }) => {
  function handleAddTodo() {
    if (content) {
      actions.addTodo({ id: genId(), content: input });
      actions.setInput('');
    }
  }
  return (
    <button onClick={handleAddTodo}>
      Add Todo
    </button>
  );
})

export default const MemoizedAddTodo = () => {
  const [state, actions] = useContext(appContext);
  return (
    <AddTodo input={state.input} actions={actions} />
  );
}

We can't use useContext right in AddTodo because it will call re-render on context update whether memo has used or not. So we have to wrap it and use props instead.

Let's try Flex Reducer.

// AddTodo.js
import { useSelector } from 'flex-reducer';
import { addTodo, setInput } from "./TodoApp";

const genId = () => Math.rand();

export default const AddTodo = React.memo(() => {
  const content = useSelector(state => state.app.input);
  function handleAddTodo() {
    if (content) {
      addTodo({ id: genId(), content });
      setInput('');
    }
  }
  return (
    <button onClick={handleAddTodo}>
      Add Todo
    </button>
  );
})

Nice. No need in additional wrapper. Thanks to useSelector which call re-render only if input changed.

But everything in the world have its pros and cons.
Let's compare how it works with remote data when use a declarative way, for example react-query.
In case of built-in useReducer.

// TodoApp.js
import { useReducer, createContext, useMemo } from 'react';
import { useQuery } from 'react-query';
import AddTodo from './AddTodo';
import TodoList from './TodoList';

export const AppContext = createContext(null);
const cache = {};

export default function TodoApp() {
  const [reducerState, dispatch] = useReducer(reducer, cache.state || initialState);
  cache.state = reducerState;
  const actions = useMemo(() => ({
    setInput: (value) => {
      dispatch({
        type: 'SET_INPUT', 
        payload: value
      })
    },
    addTodo: ({ id, content }) => {
      dispatch({
        type: 'ADD_TODO',
        payload: { id, content }
      })
    }
  }), []);

  const todos = useQuery('todos', fetchTodoList);
  const state = { ...reducerState, todos };

  return (
    <AppContext.Provider value=[state, actions]>
      <div className="todo-app">
        <h1>{state.title}</h1>
        <input value={state.input} onChange={e => actions.setInput(e.target.value)} />
        <AddTodo />
        <TodoList />
      </div>
    </AppContext>
  );
}

Perfect. Simple and readable.

Let's try the same with Flex Reducer.

// TodoApp.js
import { useFlexReducer, dispatch } from 'flex-reducer';
import { useQuery } from 'react-query';
import AddTodo from './AddTodo';
import TodoList from './TodoList';

export const setInput = (value) => dispatch({
  type: SET_INPUT,
  payload: value
});
export const addTodo = ({ id, content }) => dispatch({
  type: ADD_TODO,
  payload: { id, content }
});
export const setTodos = (todos) => dispatch({
  type: SET_TODOS,
  payload: todos
});

export default function TodoApp() {
  const [state] = useFlexReducer('app', reducer, initialState);
  const todos = useQuery('todos', fetchTodoList);
  React.useEffect(() => {
    setTodos(todos);
  }, [todos]);

  return (
    <div className="todo-app">
      <h1>{state.title}</h1>
      <input value={state.input} onChange={e => setInput(e.target.value)} />
      <AddTodo />
      <TodoList />
    </div>
  );
}

We got a problem with additional render when we update our reducer state on every todos query update.

Conclusion
Using useReducer + useContext for state management are pretty good. But it requires to be careful about context and cache.
Flex Reducer takes this work, improves readability, memo optimization and reduces code base. But it's worse when you work with remote data in a declarative way (e.g. react-query).

Warning!
Flex Reducer is an experiment and hasn't been used in production yet.

Thanks for reading. Appreciate any thoughts.

Full code of the Todo app you can find here.
Link to Flex Reducer repository

Discussion

markdown guide
 

Interesting thing, thanks! I like how it reduces boilerplate.
But what i do not like at all is the way how you propose to use it with declarative data loaders. I think a better solution would be to use react context for the data from a loader. The other way would be to create your own declarative data loading solution which will use the flex reducer to store the data instead of a built in useReducer.

 

Thank you for your feedback.
Yes, you are right, and that's what I'm talking about. The imperative Redux-like style to keep all data in state doesn't work well with declarative style like react-query does.
I like your suggestion to use context for propagate loaded data, it could be a good way.
About create own declarative data loader - not sure how to merge this 2 approaches that it looks good. It can be confusing when loader returns data and also put it somewhere in state automatically and calls re-render.

 

i am not sure if i understood why you think redux is bad for reactjs

 

Interesting. I don't think I've had a chance to play with unstable_batchedUpdates yet (in my own code, maybe with a third party lib).

I figured useRef was how you set up the selector capability (I've been thinking of doing with React context), and I was right.

Nice project.