DEV Community

Cover image for React doesn't need state management tool, I said
Štěpán Granát for Tolgee

Posted on

React doesn't need state management tool, I said

From time to time someone still tells me that is using REDUX or similar tool in their project. I usually respond, that I wouldn't use it as now with hooks and context API you don't need it.

But context API usually brings performance problems and is also a bit awkward to use it properly, so today I'll try to show how to avoid common problems, and also build your own (micro) state management tool, without any compromises.

Naive solution

Basic idea is to manage state in one component and pass the whole it by context so it's accessible from all child components, so we can avoid props drilling.

export const StateContext = createContext(null);
const Provider = () => {
  return (
    <StateContext.Provider value={state}>
      <ChildComponent />
    </StateContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using dispatch

However you also need some way how to modify the state from children, you could pass individual functions to the context, but I personally don't like that as the state will get complex very fast. I like idea of dispatching events (similarly as in REDUX), so we basically pass one function which you can use to dispatch all different actions that you need. We could pass it through the same context as the state, but I don't like mixing it with the state, so I pass it through a separate context.

const StateContext = createContext(null);
const DispatchContext = createContext(null);

export const Provider = () => {
  const [state, setState] = useState(...)

  const dispatch = (action) => {
    switch (action.type) {
      case 'CHANGE_STATE':
        setState(action.payload)
        break;
      ...
    }
  }

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        <ChildComponent />
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

I also like creating hook for getting the dispatch function to make it more explicit:

export const useDispatch = () => {
  return useContext(DispatchContext)
}
Enter fullscreen mode Exit fullscreen mode

Basically we are separating data from actions - provider component provides data to children. Children can dispatch actions to modify the data, but it's controlled by provider component, so it has control over it. Dispatched actions can be understood similarly as e.g. dom events, except we know who will receive it.

Image description

Now let's look at the performance side as if we want to use this as a replacement of REDUX, it needs to be able to handle big states with a lot of components subscribed.

Avoiding unnecessary children re-creation

In this configuration we are really inefficient, as all the children will get re-rendered every time we change something in the state. This happens because every time we update state in Provider component, all it's children will get re-created. We could use React.memo on children to avoid this, however nicer solution is to pass children from component above, so when the Provider is updated, children will stay the same. And we only update actual context consumers.

export const Provider = ({ children }) => {

  ...

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

In parent we do:

export const Parent = ({ children }) => {
  return (
    <Provider>
      <ChildComponent />
    </Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now the provider component is managing the context, but is not managing children (only passing them). It took me a while to understand this subtle difference as it is quite small change in the code, with big consequences.

The trick is to understand, that when we put <ChildComponent >, we are basically creating new React.Node every time, so all the children are re-rendered, unless we wrap them in React.memo.

So with this change, we update only components which are using the context.

Avoiding dispatch causing re-renders

Currently dispatch function is re-created every time the state is changed, which mean that all components using it will get re-rended, even though they are not using StateContext. Usually if we want to have stable function react documentation advices to use useCallback, but in this case it will help us only partially, because, that will basically cause "caching" of dispatch function and we wouldn't be able to use outer scope variables without including them into dependencies - and then the dispatch function would still get recreated when dependencies change. We will need to use ref to help us with this.

...

export const Provider = ({ children }) => {
  const [state, setState] = useState(...)

  const dispatchRef = useRef()

  // new function with every render
  const dispatchRef.current = (action) => {
    switch (action.type) {
      case 'CHANGE_STATE':
        // we can use outer scope without restrictions
        setState({...action.payload, ...state})
        break;
      ...
    }
  }

  // stable dispatch function
  const dispatch = useCallback(
    (action: ActionType) => dispatchRef.current(action),
    [dispatchRef]
  );

  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

This way stable dispatch function is passed to the DispatchContext and we can use outer scope without limitations.

Subscribable context

Last optimization we'll need is ability of the component subscribe only to part of the state. Now components can only use whole state and even when they need just small piece (e.g. one boolean value), they'll get notified every we change the state. This is not the best practice as we would still get unnecessary re-renders. The way to solve this is through use-context-selector.

This library is quite simple and it allows to use selector function, to "pick" what we want from the state.

import { createContext } from 'use-context-selector';

const StateContext = createContext(null);

export const Provider = ({ children }) => {
  return (
    <StateContext.Provider value={{state, ...}}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode
import { useContextSelector } from 'use-context-selector';

export const Subscriber = () => {
  const somePart = useContextSelector(StateContext, context => context.somePart)
}
Enter fullscreen mode Exit fullscreen mode

Oh, wait that is cheating! You said you will only use Context API!

This library is quite simple wrapper of React.Context api. It uses ref to wrap passed value, so that components are not re-rendering automatically and then it keeps list of subscribers. When value changes it runs all the subscribed functions and if the value from the selector is different than before it forces the subscribed Component to re-render. Similar concept is used e.g. in redux useSelector hook. So I say, it's quite standard solution and why build a new one, when it already exists?

PS: there is even a open RFC to add something like this directly into react

Final product

We can wrap this whole functionality to be reusable (+ add typescript types)

import React, { useCallback, useRef } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';

type DispatchType<ActionType, DispatchReturn> = (
  action: ActionType
) => DispatchReturn;

type SelectorType<StateType> = (state: StateType) => any;

export const createProvider = <
  StateType,
  ActionType,
  DispatchReturn,
  ProviderProps
>(
  body: (
    props: ProviderProps
  ) => [state: StateType, dispatch: DispatchType<ActionType, DispatchReturn>]
) => {
  const StateContext = createContext<StateType>(null as any);
  const DispatchContext = React.createContext<
    DispatchType<ActionType, DispatchReturn>
  >(null as any);

  const Provider: React.FC<ProviderProps> = ({ children, ...props }) => {
    const [state, _dispatch] = body(props as any);
    const dispatchRef = useRef(_dispatch);

    dispatchRef.current = _dispatch;

    // stable dispatch function
    const dispatch = useCallback(
      (action: ActionType) => dispatchRef.current?.(action),
      [dispatchRef]
    );

    return (
      <StateContext.Provider value={state}>
        <DispatchContext.Provider value={dispatch}>
          {children}
        </DispatchContext.Provider>
      </StateContext.Provider>
    );
  };

  const useDispatch = () => React.useContext(DispatchContext);
  const useStateContext = (selector: SelectorType<StateType>) =>
    useContextSelector(StateContext, selector);

  return [Provider, useDispatch, useStateContext] as const;
};
Enter fullscreen mode Exit fullscreen mode

Usage example

type ActionType =
  | { type: 'CHANGE_STATE'; payload: ... }
  ...

export const [
  TranslationsContextProvider,
  useTranslationsDispatch,
  useTranslationsSelector,
] = createProvider(
  (props /* provider props */) => {
    const [state1, setState1] = useState(...)
    const [state2, setState2] = useState(...)
    const {data, isLoading} = useQuery(...)

    const dispatch = (action: ActionType) => {
      switch (action.type) {
        case 'CHANGE_STATE':
          setState(action.payload)
          break;
        ...
      }
    }

    const state = {
      state1,
      state2,
      data,
      isLoading
    }

    // don't forget to return state and dispatch function
    return [state, dispatch]
  })
Enter fullscreen mode Exit fullscreen mode

Lets summarize advantages of this solution:

  • Simple usage, nothing new to learn no boilerplate as with REDUX etc.
  • More efficient than Context api used naively
  • It scales as you have the whole power of hooks
  • You can use many instances and scope them only to the part of app that need them

In Tolgee.io, we use this on our most complicated view, where we handle translations table and we didn't have any problems with it yet.

What do you think?

PS: Check Tolgee.io and give us github stars

Latest comments (69)

Collapse
 
curlhash profile image
Abhishek Verma

Thanks for sharing a great article, I am also using a similar approach but I haven't found anything around devtool for such an approach. I'm curious are you using anything for debugging this kind of similar to redux devtool? Also, what do you think of github.com/nanostores/nanostores?

Collapse
 
safouene1 profile image
Safouene Turki

I believe if anyone tries Vuex (redux equivalent of Vue) will definitely understand what's wrong with redux.
Vuex logic is far more simple , practical and easy to learn .
It actually makes it fun to use .

Collapse
 
dkamyshov profile image
Danil Kamyshov

The problem with React Context is that (due to the nature of React itself) it behaves like a state management tool, while in reality it is a tool for dependency injection. This confusion often leads to the idea to use React Context as a state manager, which it was never intended for.

If you take a closer look at any decent state management solution, you will quickly notice that these tools never pass "naked" state (values that change frequently) directly via context, they instead wrap the state with "something" and then pass this "something" through the context. This "something" is generally called "an observable store".

Different tools use different names ("store" - redux, effector, zustand, etc.; "client (cache)" - apollo-client; "query client (cache)" - react-query), but the end result is the same - "naked" state is wrapped and the referential equality is preserved even when the state changes.

So, instead of using cryptic libraries like use-context-selector, it might be a better idea to take advantage of the "observable store" pattern. If you don't feel like using an existing solution, you can easily implement everything yourself, it's like ~30 LOC with all the infrastructure.

Collapse
 
rtmann profile image
rtmann • Edited

Just use mobx and observer. You can combine observables with hooks and the observer component wrapper for FC development and have clean state management. Use the container -> presentation pattern.. make each route in the app a container, use react router V6 object routing to pass observable stores to your containers and prop drill to presentation components so things are easier to test.

Keep presentation components dumb, containers complex.

Use outlets and contexts to handle layout.

I.e create a page that uses contexts to let children update it, do you can have

<Page...

...child route in outlet...

<Page.Content type="append">
..I am appended content.
</Page.Content>
Enter fullscreen mode Exit fullscreen mode

The parent route where page is uses an observer/observable, Content uses context to access the pages mobx observable so it can add content to the page.

Mobx memoizes components automatically for you.

I like it way better than redux.

Optionally you can use custom hooks to grab contexts for parent observable states. So you could make a hook called "usePageContent" and give it append, replace, etc methods.

Collapse
 
azattemirbek profile image
Azat Temirbek uulu

this aproach will have problems when hot module enabled. I have also developed this kind of lib for Form and FormElements. When you build form there will be no problem but when you change the body (main sate) and hot module updates it the Provider context will lost its value and recreate it from scratch since Form and form element is attached to previous context the render will fail. (using CreateContext from React)

Collapse
 
crackname36 profile image
crack Name

I like your post style as it’s unique from the others I’m seeing on the page. Skatter Crack

Collapse
 
malixsys profile image
Martin Alix

You probably don’t need useRef for dispatch if you use the function version of setState: setState(old => ({…old, key:val})) 🤔

Collapse
 
aralroca profile image
Aral Roca • Edited

What do you think about Teaful 😊?

github.com/teafuljs/teaful

I think it's even less boileplate, and you still avoid more rerenders, since it only rerenders if the store property being consumed is updated, and not others.

Collapse
 
cyrstron profile image
cyrstron

Redux is not only context provider. It is reliable and easily debugable state structure for your application.

Sure, it is complex and has a lot of boilerplate code even with RTK. But if it is too complex for your app, it means only that your app is not big enough to profit from Redux. Use native state management or simpler solutions like MobX.

Collapse
 
rangercoder99 profile image
RangerCoder99

So I take it that you never used Redux toolkit and also never had to test the data in your global store in one big project?!

Collapse
 
stepan662 profile image
Štěpán Granát

I've used REDUX about 2 years ago and now I'm working on project where the redux is used, but basically have it's there as a legacy and are trying to get rid of it. So I haven't used it yet. And store based testing? No, honestly I've never had a use case for it. We either were writing unit tests for small components or using cypress for testing the app as a whole.

Collapse
 
spock123 profile image
Lars Rye Jeppesen

Setting a bunch of hooks and effects in React components really shows what a big mess React is, compared to more elegant solutions with Angular and Vue.

Typed Observable Reactive stores is where it's at and the APIs for using those are much more composable and beautiful. I can't believe React has become such a mess, but I guess that's why it's the oldest of the big frameworks.

Collapse
 
krtirtho profile image
Kingkor Roy Tirtho

I'm crying in zustand. It's much simpler to setup & doesn't impact the bundle size much. Now everyone (REDUX FANBOIS) will say redux-toolkit makes setting up redux easier but that's what redux itself should do. Why would I add another library over already installed redux & react-redux summing up my total bundle size with another 40KB?

So I now agreed on a combo of ZUSTAND & React-Query which is highly recommended for others too (IMO)

Collapse
 
yojona profile image
Jonathan Ayala

Actually you dont need React, you can use regular html and js

Collapse
 
ozzythegiant profile image
Oziel Perez

I understand Redux has quite a bit of boiler plate but honestly, I hate Hooks so much and wish it never came out. Too many devs are writing sloppy code with them and large projects are getting extremely hard to manage and debug simply because there is no standardization of how to structure custom hooks. Never using Hooks and will continue to use Redux. Only exception is a quick component that needs useState but that's about it.

Collapse
 
shayan909 profile image
Shayan Ur Rehman

I'll stick with Redux. Context Api increases complexity

Some comments may only be visible to logged-in visitors. Sign in to view all comments.