Search code examples
javascriptreactjstypescriptreduxreact-redux

Redux State update will not cause a re-render when changed from within a custom hook


I have set up a Redux slice for which I've created a custom wrapper-hook (I hate the "{ type: x, payload: y }" syntax :p)

Here's the slice:

// polycanvas.ts (Redux)

export const polycanvas = createSlice({
  name: 'polycanvas',
  initialState,
  reducers: {
    // Arcs
    addArc: (state, action: PayloadAction<Arc>) => {
      if (state.arcs.length < state.settings.arc.maxArcs) {
        state.arcs = [...state.arcs, action.payload];
      } else {
        throw(`Number of arcs exceeds the maximum. (max: ${state.settings.arc.maxArcs}, got: ${state.arcs.length})`);
      }
    },
    removeArc: (state, action: PayloadAction<number>) => {
      if (state.arcs[action.payload]) {
        state.arcs = state.arcs.filter((_, index) => index != action.payload);
      } else {
        throw(`Tried removing arc at index ${action.payload}. No such arc exists.`);
      }
    },
    setArcs: (state, action: PayloadAction<Arc[]>) => {
      if (action.payload.length < state.settings.arc.maxArcs) {
        state.arcs = action.payload;
      } else {
        throw(`Number of arcs exceeds the maximum. (max: ${state.settings.arc.maxArcs}, got: ${action.payload.length})`);
      }
    },

    //... other stuff
  }
});

export const usePolycanvas = () => {
  const dispatch = useAppDispatch();

  const arcs = useAppSelector(state => state.polycanvas.arcs);
  const arc = {
    addArc: (arc: Arc) =>
      dispatch(polycanvas.actions.addArc(arc)),
    removeArc: (index: number) =>
      dispatch(polycanvas.actions.removeArc(index)),
    setArcs: (arcs: Arc[]) =>
      dispatch(polycanvas.actions.setArcs(arcs)),
    
    //... other stuff
  }

  return { arc, arcs };
}

As far as I'm concerned, this is correct Redux usage.

In my React Component:

// Polycanvas.tsx (React Component)

export default () => {
  const { arc, arcs } = usePolycanvas();

  const makeArcs = (arcCount: number): void => {
    const _arcs = [];
    for (var i = 0; i < arcCount; i++) {
      const velocity = (4 * Math.PI * (settings.speed.loops - i)) / settings.speed.timeframeSeconds;
      _arcs.push({
        color: { r: 255, g: 255, b: 255, a: 0.3 },
        velocity,
        nextImpactTime: calculateImpactTime(startTime, velocity),
      });
    }
    arc.setArcs(_arcs);
  }


  React.useEffect(() => {
    makeArcs(10);
  }, []);

  React.useEffect(() => {
    console.log(arcs);
  }, [arcs]);

  return (<canvas ref={ref} className="polycanvas" />);
  // Ref is used for drawing to the canvas - not relevant in this example
}

Console only prints [] , nothing actually renders unless I write to a file and Vite does its HMR magic.

Is there anything I'm doing wrong? Is this behavior not supported by Redux?

EDIT: Added CodeSandbox demo.


Solution

  • The reducers in your sandbox are still incorrectly reassigning the state value and/or mutating and returning the state value.

    See Direct State Mutation for further details.

    export const polycanvas = createSlice({
      name: 'polycanvas',
      initialState,
      reducers: {
        // Arcs
        addArc: (state, action: PayloadAction<Arc>) => {
          if (state.arcs.length < state.settings.arc.maxArcs) {
            // ERROR: don't re-assign the state value
            // state = {
            //   ...state,
            //   arcs: [...state.arcs, action.payload]
            // };
    
            // OK: mutate state directly
            state.arcs.push(action.payload);
          } else {
            throw(`Number of arcs exceeds the maximum. (max: ${state.settings.arc.maxArcs}, got: ${state.arcs.length})`);
          }
        },
        removeArc: (state, action: PayloadAction<number>) => {
          if (state.arcs[action.payload]) {
            state.arcs = state.arcs.filter((_, index) => index != action.payload);
          } else {
            throw(`Tried removing arc at index ${action.payload}. No such arc exists.`);
          }
        },
        setArcs: (state, action: PayloadAction<Arc[]>) => {
          if (action.payload.length < state.settings.arc.maxArcs) {
            // ERROR: don't re-assign the state value
            // state = {
            //   ...state,
            //   arcs: action.payload
            // }
    
            // OK: mutate state directly
            state.arcs = action.payload;
          } else {
            throw(`Number of arcs exceeds the maximum. (max: ${state.settings.arc.maxArcs}, got: ${action.payload.length})`);
          }
        },
        // Settings
        settings_setLoops: (state, action: PayloadAction<number>) => {
          state.settings.speed.loops = action.payload;
    
          // ERROR: don't mutate & return state
          // return state;
    
          // Also OK, return entirely new state value
          // return {
          //   ...state,
          //   settings: {
          //     ...state.settings,
          //     speed: {
          //       ...state.settings.speed,
          //       loops: action.payload,
          //     },
          //   },
          // };
        },
        settings_setTimeframeSeconds: (state, action: PayloadAction<number>) => {
          state.settings.speed.timeframeSeconds = action.payload;
    
          // ERROR: don't re-assign the state value
          // return state;
        },
        settings_setMaxArcs: (state, action: PayloadAction<number>) => {
          state.settings.arc.maxArcs = action.payload;
    
          // ERROR: don't re-assign the state value
          // return state;
        },
        settings_setArcDistance: (state, action: PayloadAction<number>) => {
          state.settings.arc.distance = action.payload;
    
          // ERROR: don't re-assign the state value
          // return state;
        }
      }
    });
    

    The additional issue is that the PolyCanvas is editing/setting properties on a canvas and canvas context ctx variable, but these variables are re-declared each render cycle.

    I suggest the following refactor:

    useAnimationFrame: Update signature to consume a dependency array.

    import React from "react";
    
    export default (callback: (...args: any[]) => any, deps: any[] = []) => {
      // Use useRef for mutable variables that we want to persist
      // without triggering a re-render on their change
      const requestRef = React.useRef<number>();
      const previousTimeRef = React.useRef<number>();
      
      const animate = (time: number) => {
        if (previousTimeRef.current != undefined) {
          const deltaTime = time - previousTimeRef.current;
          callback(deltaTime)
        }
        previousTimeRef.current = time;
        requestRef.current = requestAnimationFrame(animate);
      }
      
      React.useEffect(() => {
        requestRef.current = requestAnimationFrame(animate);
        return () => cancelAnimationFrame(requestRef.current!);
      }, deps);
    }
    

    Poluycanvas: Move the ctx and canvas variables outside the component (or use React refs) so they are stable references, and pass the selected arcs state as a dependency for the useAnimationFrame hook call.

    import React from "react";
    import { Arc, usePolycanvas } from "../redux/polycanvas";
    import useAnimationFrame from "../hooks/useAnimationFrame";
    
    type Coords2D = {
      x: number;
      y: number;
    }
    
    type Canvas = {
      center: Coords2D;
      start: Coords2D;
      end: Coords2D;
      length: number;
    
      strokeStyle: CanvasRenderingContext2D['strokeStyle'];
      lineWidth: CanvasRenderingContext2D['lineWidth'];
    }
    
    let ctx: CanvasRenderingContext2D | null;
    let canvas: Canvas;
    
    export default () => {
      const canvasRef = React.createRef<HTMLCanvasElement>();
      const startTime = performance.now();
    
      const { arc, arcs, settings } = usePolycanvas();
    
      const calculateImpactTime = (currentImpactTime: number, velocity: number) => {
        return currentImpactTime + (Math.PI / velocity) * 1000;
      }
        
      const makeArcs = (arcCount: number): void => {
        const _arcs = [];
        for (var i = 0; i < arcCount; i++) {
          const velocity = (4 * Math.PI * (settings.speed.loops - i)) / settings.speed.timeframeSeconds;
          _arcs.push({
            color: { r: 255, g: 255, b: 255, a: 0.3 },
            velocity,
            nextImpactTime: calculateImpactTime(startTime, velocity),
          });
        }
        arc.setArcs(_arcs);
      }
    
      React.useEffect(() => {
        makeArcs(10);
    
        if (canvasRef.current) {
          ctx = canvasRef.current?.getContext("2d");
    
          canvasRef.current.width = canvasRef.current.clientWidth;
          canvasRef.current.height = canvasRef.current.clientHeight;
    
          const center: Coords2D = {
            x: canvasRef.current.width / 2,
            y: canvasRef.current.height / 2
          };
          const start: Coords2D = {
            x: 0,
            y: canvasRef.current.height / 2
          };
          const end: Coords2D = {
            x: canvasRef.current.width,
            y: canvasRef.current.height / 2
          };
          const length = end.x;
    
          canvas = {
            center, start, end, length,
            strokeStyle: `rgba(255, 255,
              255, 0.3)`,
            lineWidth: 1,
          }
        }
      }, []);
    
      React.useEffect(() => {
        console.log(arcs);
      }, [arcs]);
        
      const drawArcs = () => {
        if (canvas && ctx) {
          const initialArcRadius = canvas.length * settings.arc.distance;
          const spacing = (canvas.length / 2 - initialArcRadius) / arcs.length;
    
          arcs.forEach((arc: Arc, index: number) => {
            
            if (ctx) {
              const radius = initialArcRadius + (index * spacing);
              ctx.beginPath();
              ctx.arc(canvas.center.x, canvas.center.y, radius, Math.PI * 2, 0);
    
              ctx.strokeStyle = `rgba(${arc.color.r}, ${arc.color.g},
                ${arc.color.b}, ${arc.color.a})`;
    
              ctx.lineWidth = ctx.lineWidth;
    
              ctx.stroke();
            }
          });
        }
      }
    
      const clearCanvas = () => {
        if (ctx) {
          ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
        }
      };
        
      useAnimationFrame(() => {
        clearCanvas();
        drawArcs();
      }, [arcs]);
    
      return (
        <canvas ref={canvasRef} className="poly-canvas" />
      );
    }