DEV Community

Cover image for Enhancing useReducer.
Roman Zhernosek
Roman Zhernosek

Posted on

16 6

Enhancing useReducer.

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

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (4)

Collapse
 
c58 profile image
Artem Artemyev

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.

Collapse
 
ipshot profile image
Roman Zhernosek

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.

Collapse
 
tilakmaddy_68 profile image
Tilak Madichetti

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

Collapse
 
stereoplegic profile image
Mike Bybee

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.

The best way to debug slow web pages cover image

The best way to debug slow web pages

Tools like Page Speed Insights and Google Lighthouse are great for providing advice for front end performance issues. But what these tools can’t do, is evaluate performance across your entire stack of distributed services and applications.

Watch video

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay