2

How can I set up a connection inside useEffect and at the same time decorate that connection with a custom hook? Custom hooks are not allowed to run inside useEffect and ref.current is not permitted during rendering. What is the proper way to wrap a connection with a React-friendly interface?

I believe the right way to set up a WebSocket connection in React, is with a useEffect hook, along the lines of

const connection = useRef(null);

useEffect(() => {
  const ws = new WebSocket(socketUrl);
  connection.current = ws;

  return () => {
    ws.close();
    connection.current = null;
  }
}, [socketUrl]);

In a similar way I envisage creating a RTCDataConnection, which follows the same connection interface with .send('...') and open, close, error and message events.

I am looking for a way to provide a React-like interface to these connections. I was thinking something like the following custom useConnection hook, which takes a JavaScript connection object and in return provides two React state variables lastMessage and readyState, and a method sendMessage:

export function useConnection(connection) {
  const [lastMessage, setLastMessage] = useState(null);
  const [readyState, setReadyState] = useState(null);

  const onReadyStateChange = useCallback(
    (e) => {
      console.info(e);
      setReadyState(connection.readyState);
    },
    [setReadyState]
  );

  const onMessage = useCallback(
    ({ data }) => {
      const message = JSON.parse(data);
      setLastMessage(message);
    },
    [setLastMessage]
  );

  function sendMessage(message) {
    const msgJSON = JSON.stringify(message);
    connection.send(msgJSON);
  }

  useEffect(() => {
    connection.addEventListener('open', onReadyStateChange);
    connection.addEventListener('close', onReadyStateChange);
    connection.addEventListener('error', onReadyStateChange);
    connection.addEventListener('message', onMessage);

    return () => {
      connection.removeEventListener('open', onReadyStateChange);
      connection.removeEventListener('close', onReadyStateChange);
      connection.removeEventListener('error', onReadyStateChange);
      connection.removeEventListener('message', onMessage);
    };
  }, [connection]);

  return { lastMessage, readyState, sendMessage };
}

I can't find out, however, how to properly apply the hook to the connection.

  • By the rules of hooks, I can't call useConnection inside useEffect. It throws "Invalid hook call" at me.
  • I can't reference connection.current outside the useEffect either, because that violates the rules of useRef: It is not allowed to access the .current property during rendering.

So my question is, how do I bring the connection and the hook together? Or should I use a different approach altogether?

0

3 Answers 3

1

If you want to do this, i.e. call the hook

const {lastMessage, readyState, sendMessage} = useConnection(something);

from inside useEffect because it is the only place where you have access to something so this is not possible.
hooks should be called in the body of your function component not conditionally and not inside a loop you can think of them like an import.

solution:

create a state connection and use its value as useConnection function parameter

const [connection, setConnection] = useState(null);
const { lastMessage, readyState, sendMessage } = useConnection(connection);

Note: you may need to make some extra work in your hook to handle null connection received because this will happen when the hook is called in the component first mount

Now from useEffect instead of call the hook, you just update connection state with the value you want to call your custom hook with i.e something.
when the state is updated, the component re-renders and your hook function will be recalled if it receives a new value as parameter so when connection gets a new value, therefore it returns a new {lastMessage, readyState, sendMessage} values that you can have access to, in this new render

so if you want to use them directly inside your useEffect just after updating connection state, then this is not possible, however, you can still create another useEffect that fire each time connection is updated, so after the component re-renders by updating connection in the first useEffect now since it is a new render you will have access to the new values returned by the hook in the second useEffect.

useEffect(() => {
 // continue your logic here
 if(connection){
   sendMessage("new msg")
 }
},[connection?.id]) 

The function should work with the expected connection object when executes connection.send(msgJSON) since it is a new instance that receives the expected connection here I added connection?.id as dependency instead of connection because we want to avoid including object, such a key should exist in the connection object

Note that useEffect hook is firing each time values in its dependency array are updated but also in the component first mount.

Sign up to request clarification or add additional context in comments.

5 Comments

So if I get it right, what you're proposing is to replace useRef by useState, then the two hooks useState and useConnection can live next to each other happily. That makes it a workaround for the useRef rule that could certainly work. My only concern is I've nowhere seen people put WebSocket in a state variable. Is that an accepted thing to do? Why do others not do that? Perhaps can you clarify this choice a little in the answer?
here I try to give a solution to how you can make it work with a custom hook just to answer the question and explain why you can't do what you want to do and yes I used to store the socket object in a context and call it from different components in fact it is an object so you can store it in a state I don't see anything wrong with that, now about it is a workaround for ref, the difference is that when you update the ref the component won't rerender and the custom hook will not be recalled when your ref is receiving a new socket object however when you use a state, it will do
it is true that the hook will be called on each render since we pass an object as a parameter to it (that what the implementation requires) but hopefully the code inside the second useEffect will run only when connection?.id changes so that's ok in fact this is why you don't want to include connectyion in the dependency array to avoid firing the second useEffect on each render
That's clever, the state causing a re-render where the ref doesn't. I don't quite like adding extra renders but it is definitely a solution that works. (Also need to add the connection.id on the useCallback by the way, but verified working solution).
in terms of renders you are right if you can manage to do all the work from inside the effect, this will save you one render but it is not possible at least with this solution of using a custom hook to manage that
0

What you would probably want is to call your hook at the top level of your component and then use it's returned parts inside the useEffect()

const {lastMessage, readyState, sendMessage} = useConnection(connection);

useEffect(() => {
    sendMessage("Hi");
}, [sendMessage]);

Your approach looks fantastic!

Comments

0

Eventually, I redesigned useConnection to take a factory function rather than the connection object directly. Not only does this circumvent the useRef, it also makes the calling code much cleaner (and more aligned with the React devs views). This is now how to use the useConnection hook:

const { lastMessage, readyState, sendMessage } = useConnection(
  () => new WebSocket(socketUrl)
);

That's all. (You can wrap that in a useMemo if socketUrl is variable.) useConnection now looks like this:

/**
 * Provides a React-friendly interface around a JS connection.
 * It exposes two state variables: lastMessage and readyState,
 * and one method sendMessage(json).
 * factory is a factory function that creates a new connection.
 */
export function useConnection(factory) {
  const empty = () => {};

  let sendMessage = empty;
  const [lastMessage, setLastMessage] = useState('');
  const [readyState, setReadyState] = useState(null);

  // The two event handlers do not depend on the connection
  // and are therefore declared outside `useEffect`.

  const handleMessage = ({ data }) => {
    const message = JSON.parse(data);
    setLastMessage(message);
  };

  const handleReadyStateChange = (e) => {
    console.debug(e);
    setReadyState(e.target.readyState);
  };

  // The effect, setting up and tearing down the connection,
  // depends on the factory function.

  useEffect(() => {
    let conn = null;

    const connect = () => {
      console.log('✅ Connecting...');
      conn = factory();
      conn.addEventListener('open', handleReadyStateChange);
      conn.addEventListener('close', handleReadyStateChange);
      conn.addEventListener('error', handleReadyStateChange);
      conn.addEventListener('message', handleMessage);
      sendMessage = send;
    };

    const disconnect = () => {
      console.log('❌ Disconnecting...');
      conn.removeEventListener('open', handleReadyStateChange);
      conn.removeEventListener('close', handleReadyStateChange);
      conn.removeEventListener('error', handleReadyStateChange);
      conn.removeEventListener('message', handleMessage);
      // Avoid an error message in the console saying:
      // "WebSocket is closed before the connection is established."
      if (conn.readyState === 1) {
        conn.close();
      }
      sendMessage = empty;
      setReadyState(conn.readyState);
      conn = null;
    };

    // The send method does depend on the connection and so
    // is declared inside `useEffect`.

    const send = (message) => {
      if (conn) {
        const msgJSON = JSON.stringify(message);
        conn.send(msgJSON);
      }
    };

    connect();

    return disconnect;
  }, [factory]);

  return { lastMessage, readyState, sendMessage };
}

The accepted answer being closer to the original question, I will leave that as is.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.