skip to Main Content

I am using a custom hook with a timer to update a Context Provider. I then wrap the Provider component around the components that need the timer.

export const useTimer = () => {
  const [a, setA] = useState()
  const [b, setB] = useState()
  const [c, setC] = useState()

  // Start the timer
  useEffect(() => {
    const interval = setInterval(() => {

      // Do some calculations on a, b, c
      // a, b & c change independently of each other
      // but not necessarily at each interval:
      // sometimes they don't change at all,
      // other times only one or two of them changes,
      // sometimes all three.
      // then setA(someValue), setB(someValue) etc

    }, 30)
    return () => clearInterval(interval)
  }, [])

  return [a, b, c]
}

export const TimerContext = createContext()

export const TimerProvider = ({ children }) => {
  
  return (
    <TimerContext.Provider value={useTimer()}>
      {children}
    </TimerContext.Provider>
  )
}

Because I’m returning a new array from useTimer every time, it updates the Provider every 30ms, not when the values change. This causes unnecessary rerenders in the wrapped components. If I return an object instead the result is the same, because it’s a new object. And I can’t return multiple independent variables in JavaScript.

Even if I memoize the array in the timer, the TimerContext sees the return value as having changed and is retriggered, I don’t know why.

const time = useMemo(() => ([a, b, c]), [a, b, c])
return time

Wrapping TimerProvider inside React.memo doesn’t help either.

Moreover, some components only need a and b, while others only need c. I’d like to create independent Contexts, all based on a single running timer, which only update when the needed values have changed.

All values are cyclic, although of different cycles. The cycles are known, though. I guess I could encode the values into a single integer but this seems a bit insane to do every 30ms and also seems overly complex for such a simple requirement.

How can I get around this?

3

Answers


  1. Chosen as BEST ANSWER

    Answering my own question in case it helps someone. After many attempts I've found a solution that does exactly what I need, although I can't yet tell how it performs CPU-wise, I'll have to make some tests.

    The idea is not to memoize the return object from the timer, but rather to memoize inside the TimerProvider component each combination of values you need to monitor and then switch between them, passing the required value to the Context.

    This allows the usage of a single generic TimerProvider which can wrap any component that needs a combination of the values by passing it, as prop, a string with the required configuration.

    I am still very much interested in a more elegant and possibly more performant solution.

    export const TimerProvider = ({ config, update, children }) => {
      const timer = useTimer(config)
    
      // Case for monitoring one value
      const case1 = timer.a
    
      // Case for a specific combination
      const case2 = useMemo(
        () => {
          return { a: timer.a, c: timer.c }
        },
        [timer.a, timer.c]
      )
    
      // Another combination
      const case3 = useMemo(
        () => {
          return { b: timer.b, c: timer.c }
        },
        [timer.b, timer.c]
      )
    
      const value = (() => {
        switch (update) {
          case 'case1':
            return case1
          case 'case2':
            return case2
          case 'case3':
            return case3
          default:
            return timer
        }
      })()
    
      return (
        <TimerContext.Provider value={value}>
            {children}
        </TimerContext.Provider>
      )
    }
    

    Example usage:

    // a component that only needs updates on 'a'
    <TimerProvider update={'case1'}>
      <SomeComponent/>
    </TimerProvider>
    

  2. Moreover, some components only need a and b, while others only need c. I’d like to create independent Contexts, all based on a single running timer, which only update when the needed values have changed.

    Yes, you can do that; there’s no reason that you have to feed the return-value of useTimer() directly into a context.

    export const TimerProvider = ({ children }) => {
      const [a, b, c] = useTimer();
    
      return (
        <TimerAContext.Provider value={a}>
          <TimerBContext.Provider value={b}>
            <TimerCContext.Provider value={c}>
              {children}
            </TimerCContext.Provider>
          </TimerBContext.Provider>
        </TimerAContext.Provider>
      )
    }
    

    That also addresses your issue with the array being different every time, since you don’t pass the actual array into the context.


    Even if I memoize the array in the timer, the TimerContext sees the return value as having changed and is retriggered, I don’t know why.

    That’s very strange. Is it possible that one of the values is actually changing every time, without your realizing it? That would defeat the memoization, obviously.

    Login or Signup to reply.
  3. The main problem with React it’s state manager is only at the component level, when you want to distribute state it get’s messy, using things like React.memo etc, is often used to prevent performance issues, when in most cases it’s the way state is used that’s the issue. I have React apps now with hundreds of components, accessing remote DB’s etc, and have yet not found any reasons to use React.memo, useMemo / useCallback etc.

    As I mentioned in comments it’s often best to use a third party state manager when you need more than just simply component state.

    One thing to always remember about React’s rendering pipeline, it’s granularity is as the component level, so the more you keep components small the better.

    can be accomplished in plain React

    Yes, all we have to do is use useState differently. Steeling the idea of other state managers like https://jotai.org/ we could create a very simple atomic wrapper for the state.

    Basically the createAtom function uses the browsers built in EventTarget to distribute state.

    Below is a simple example, you will notice in the console how the components are only getting rendered when it’s particular value changes. I’ve also done 2 renders for and 2 separate contexts to see how you can pass the state down using useContext.

    const {useEffect, useState, createContext} = React;
    
    const abcContext = createContext();
    
    
    function createAtom(_def) {
      const evt = new EventTarget();
      let _value = _def;
      function use() {
        const [, _refresher] = useState(0);
        useEffect(() => {
          evt.addEventListener('notify', () => {
            _refresher(c => c + 1);
          });
        }, []);
        return _value;
      } 
      function setValue(v) {
        _value = v;
        evt.dispatchEvent(new Event('notify'));
      }
      const value = () => _value;
      return {value, setValue, use};
    }
    
    
    function DrawA() {
      const ctx = React.useContext(abcContext);
      const value = ctx.a.use();
      console.log('render A');
      return <div>A = {value}</div>;
    }
    
    function DrawB() {
      const ctx = React.useContext(abcContext);
      const value = ctx.b.use();
      console.log('render B');
      return <div>B = {value}</div>;
    }
    
    function DrawC() {
      const ctx = React.useContext(abcContext);
      const value = ctx.c.use();
      console.log('render C');
      return <div>C = {value}</div>;
    }
    
    
    
    function ABCRandomer() {
      const ctx = React.useContext(abcContext);
      useEffect(() => {
        const i = setInterval(() => {
          const r = Math.random();
          if (r < 0.33) ctx.a.setValue(r)
          else if (r < 0.66) ctx.b.setValue(r)
          else ctx.c.setValue(r);
        }, 1000);
        return () => { clearInterval(i); }
      }, []);
      return <React.Fragment/>
    }
    
    
    function DrawABC() {
      const a = createAtom(0);
      const b = createAtom(1);
      const c = createAtom(2);
      return <abcContext.Provider value={{a,b,c}}>
        <ABCRandomer/>
        <DrawA/>
        <DrawB/>
        <DrawC/>
        <hr/>
        <DrawA/>
        <DrawB/>
        <DrawC/>
       </abcContext.Provider>;
    }
    
    function Test() {
      return <table>
        <thead>
          <tr><th>Context 1</th><th>Context 2</th></tr>
        </thead>
        <tbody>
          <tr>
            <td> <DrawABC/> </td>
            <td> <DrawABC/> </td>
          </tr>
        </tbody>
      </table>
    }
    
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<Test/>);
    table {
      width: 100%;
    }
    
    td {
      border: 1px solid black;
    }
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    
    <div id="root"></div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search