reactjsreact-hooksuse-effectuse-context

How to use context values in useEffect, that only runs once


i've got an interesting problem here. I am building a react application using web socket communication with the server. I create this websocket in a useEffect hook, which therefore cannot run multiple times, otherwise i'd end up with multiple connections. In this useEffect, however i intend to use some variables,which are actually in a context (useContext) hook. And when the context values change, the values in useEffect , understandably, don't update. I've tried useRef, but didn't work. Do you have any ideas?

const ws = useRef<WebSocket>();

  useEffect(() => {
    ws.current = new WebSocket("ws://localhost:5000");
    ws.current.addEventListener("open", () => {
      console.log("opened connection");
    });

    ws.current.addEventListener("message", (message) => {
      const messageData: ResponseData = JSON.parse(message.data);
      const { response, reload } = messageData;

      if (typeof response === "string") {
        const event = new CustomEvent<ResponseData>(response, {
          detail: messageData,
        });
        ws.current?.dispatchEvent(event);
      } else {
        if (reload !== undefined) {
          console.log("general info should reload now");
          GeneralInfoContext.reload(reload);
        }
        console.log(messageData);
      }
    });
  });

The web socket is stored as a ref for better use in different functions outside of this useEffect block

Note: the context value to be used is actually a function, GeneralInfoContext.reload()


Solution

  • Solution with split useEffect

    You can split the logic that opens the websocket connection vs. the one that adds the message handler into separate useEffects - the first can run once, while the second can re-attach the event every time a dependency changes:

    useEffect(() => {
        ws.current = new WebSocket("ws://localhost:5000");
        ws.current.addEventListener("open", () => {
            console.log("opened connection");
        });
    }, []);
    
    useEffect(() => {
        const socket = ws.current;
        if(!socket) throw new Error("Expected to have a websocket instance");
        const handler = (message) => {
            /*...*/
        }
        socket.addEventListener("message", handler);
        // cleanup
        return () => socket.removeEventListener("message", handler);
    }, [/* deps here*/])
    

    The effects will run in order so the second effect will run after the first effect has already set ws.current.


    Solution with callback ref

    Alternatively you could put the handler into a ref and update it as necessary, and reference the ref when calling the event:

    const handlerRef = useRef(() => {})
    
    useEffect(() => {
        handlerRef.current = (message) => {
            /*...*/
        }
        // No deps here, can update the function on every render
    });
    
    useEffect(() => {
        ws.current = new WebSocket("ws://localhost:5000");
        ws.current.addEventListener("open", () => {
            console.log("opened connection");
        });
    
        const handlerFunc = (message) => handlerRef.current(message);
        ws.current.addEventListener("message", handlerFunc);
        return () => ws.current.removeEventListener("message", handlerFunc);
    }, []);
    

    It's important that you don't do addEventListener("message", handlerRef.current) as that will only attach the original version of the function - the extra (message) => handlerRef.current(message) wrapper is necessary so that every message gets passed to the latest version of the handler func.

    This approach still requires two useEffect as it's best to not put handlerRef.current = /* func */ directly in the render logic, as rendering shouldn't have side-effects.


    Which to use?

    I like the first one personally, detaching and reattaching event handlers should be harmless (and basically 'free') and feels less complicated than adding an additional ref.

    But the second one avoids the need for an explicit dependency list, which is nice too, especially if you aren't using the eslint rule to ensure exhaustive deps. (Though you definitely should be)