Search code examples
javascriptreactjsreduxreact-state-management

React global state no context or redux?


I recently game across the following article State Management with React Hooks — No Redux or Context API. Since reacts inception the most talked about issue is always state management and global state. Redux has been the popular choice and more recently the context API. But this approach seems to be much easier, less code and more scalable.

My question is can anyone see a down side to using the this type of state management approach that I may have overlooked. I have tweeked the code a little to support SSR and it works in Nextjs and also made it a little more friendly to use actions and the setting of the state variable.

useGlobalState.js

import React, { useState, useEffect, useLayoutEffect } from 'react';

const effect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

function setState(newState) {
    if (newState === this.state) return;
    this.state = newState;
    this.listeners.forEach((listener) => {
        listener(this.state);
    });
}

function useCustom() {
    const newListener = useState()[1];
    effect(() => {
        this.listeners.push(newListener);
        return () => {
            this.listeners = this.listeners.filter((listener) => listener !== newListener);
        };
    }, []);
    return [this.state, this.setState, this.actions];
}

function associateActions(store, actions) {
    const associatedActions = {};
    if (actions) {
        Object.keys(actions).forEach((key) => {
            if (typeof actions[key] === 'function') {
                associatedActions[key] = actions[key].bind(null, store);
            }
            if (typeof actions[key] === 'object') {
                associatedActions[key] = associateActions(store, actions[key]);
            }
        });
    }
    return associatedActions;
}

const useGlobalHook = (initialState, actions) => {
    const store = { state: initialState, listeners: [] };
    store.setState = setState.bind(store);
    store.actions = associateActions(store, actions);
    return useCustom.bind(store, React);
};

export default useGlobalHook;

Then set up a custom hook for a state variable can be a simple string or a object here is a simple one:

import useGlobalState from './useGlobalState';

const initialState = 'Hi';

// Example action for complex processes setState will be passed to component for use as well
const someAction = (store, val) => store.setState(val); 

const useValue = useGlobalState(initialState, { someAction });

export default useValue;

And use in component:

import React from 'react'
import useVal from './useVal'

export default () => {
  const [val, setVal, actions] = useVal();

  const handleClick = () => {
    setVal('New Val');
    // or use some actions
    actions.someAction('New Val');
  }

  return(
    <div>{val}</div>
    <button onClick={handleClick}>Click Me</button>
  )
}

This all seems like a much cleaner and easier approach and I am wondering why this isn't the go to approach for state management in react. First you don't have to wrap everything in a provider. Next it is extremely easy to implement and much less code is involved in the actual app. Can anyone see a downside to using this approach. The only thing I can think of is the re rendering issue that the context api has but in small chunks this shouldn't be an issue.


Solution

  • I have been using a similar approach and I really like it. I actually can't believe more people don't talk about this approach. I wrote a custom hook here React Superstore. It gives you the freedom to dispatch from anywhere in the app and shallow compares to avoid unwanted re-renders. I don't see any performance issues as long as you can avoid the unwanted re-renders.

    In all it is a simple concept. You basically create a function to store your state and return 2 functions. One will be a function to set the stored state and one will be a hook to be used in the react component. In the hook you grab the setState function of react on initial render with a createEffect and store it in an array. You can then use this setState function to re render your component. So when you call the dispatch function you can just loop through these setState functions and call them.

    Simple example:

    import { useState, useEffect } from 'react'
    
    const createStore = (initialStore) => {
      let store = initialStore
      const listeners = new Set()
    
      const dispatch = (newStore) => {
        // Make it like reacts setState so if you pass in a function you can get the store value first
        store = typeof newStore === 'function' ? newStore(store) : newStore
        listeners.forEach(listener => listener(() => store))
      }
    
      const useStore = () => {
        const [, listener] = useState()
        useEffect(() => {
          listeners.add(listener)
          return () => listeners.delete(listener)
        }, [])
        return store
      }
    
      return [useStore, dispatch]
    }
    

    Edit for React 18 using useSyncExternalStore:

    import { useSyncExternalStore } from 'react'
    
    const createStore = (initialStore) => {
      let store = initialStore
      const listeners = new Set()
    
      const getStore = () => store
    
      const dispatch = (newStore) => {
        // Make it like reacts setState so if you pass in a function you can get the store value first
        store = typeof newStore === 'function' ? newStore(store) : newStore
        listeners.forEach(listener => listener(() => store))
      }
    
      const subscribe = (listener) => {
        listeners.add(listener)
        return () => listeners.delete(listener)
      }
    
      const useStore = () => {
        return useSyncExternalStore(subscribe, getStore)
      }
    
      return [useStore, dispatch]
    }
    

    Then just create a store and use in your component

    const [useCount, setCount] = createStore(0)
    
    const Display = () => {
      const count = useCount()
      return <div>{count}</div>
    }
    
    const addToCount = () => 
      <button onClick={ () => setCount(count => count + 1}>+</button>
    

    You can also just create a file with all of your stores in it and just export them from there and use them anywhere in your app. You can use them inside or outside of react components.

    export const [useCount, setCount] = createStore(0)
    

    Then if you want to have a complex store object and you want to avoid re renders you can do a shallow compare in the dispatch function to compare the store to the new store similar to what redux does. Something like the following:

    const shouldUpdate = (a, b) => {
      // (b) needs to be an object or you will have to do checks before looping make sure your store is an object or check it in this function
      for( let key in b ) {
        if(b[key] !== a[key]) return true
      }
      
      return false
    }
    

    and then in dispatch you can check this before firing the listener in your forEach loop.

    const dispatch = (newStore) => {
      const oldStore = store
      store = typeof newStore === 'function' ? newStore(store) : newstore
    
      if(!shouldUpdate(oldStore, store) return
    
      listeners.forEach(listener => listener(() => store))
    }
    

    Its way less boilerplate than redux and seems to be much cleaner. The best thing is it allows you to decouple your actions from functions without attaching the actions to anything. You can simply create a store anywhere in your app and export the useStore and dispatch functions. Then you can dispatch from anywhere in your app.