The Catch with React Hooks

Are function components trying to do too much?
Jan. 21, 2021

Solved Problems

When I first cut my teeth with web development in 2012, the browser landscape was competitive. Safari was still on Windows, Firefox still had a large user base, and IE 11 would soon be released. New to CSS at the time, I remember someone explaining that the bevy of browser-prefixed properties was part of the natural ebb-and-flow of browser competition, that browsers created new features to get ahead, but the feature wasn’t standard, so they prefixed it, and then everyone adopted it, so then it became a non-prefixed standard, but then another browser went off and created a new prefixed CSS feature… and on and on, and the sentiment was that this would go on for forever. Until Chrome became even more mainstream and developers realized it was so much better to develop on than the alternatives. We don’t have “browser wars” anymore because building a functional browser that meets basic developer needs is largely a solved problem.

I see a similar thing happening with front-end frameworks today. React has largely won the framework war. “Framework fatigue” was a buzzword a few years ago, but not now. Fatigue is only fatigue until a good-enough solution is widely adopted, and then people move on to solve the next problem. Sure, there are other viable front-end frameworks and occasional contenders, but React has stayed on top for years, for much longer than people thought any front-end framework could stay on top for. The front-end framework is in some ways a solved problem, at least in a “good-enough to create UIs in a maintainable, simple way” sense, and people are busy solving the next hurdle in creating great web experiences with less time, hassle, and bugginess.

Why I'm Writing This

Ever since I started using React in 2016, I’ve felt that the React core team has made so many good choices. Even though tooling was initially a nightmare and a time-suck to set up a basic project, the unidirectional data flow, JSX, simple, small API, and clear, minimal documentation made React a joy to work with. Put simply, it was just a better idea for writing apps than other front-end frameworks at the time. In the intervening years, it has stayed great with a sustainable flow of new ideas and features.

At the same time, I’ve felt from early on that hooks were trouble, especially the ones that introduce side-effects into a previously pure function component. Hooks introduce React-specific syntax and concepts, they hurt readability, they don’t scale well at the component level, and they stretch an abstraction to the breaking point. Below I’ve addressed each of these points and illustrate some of the pain points through a code example.

React-Specific Syntax and Concepts

React stood out to me (over Ember and AngularJS) because there were fewer framework-specific concepts and a smaller API. As has been said, React’s API surface has gotten much larger with hooks. Much of the added API is React-specific, and even hooks-specific (for example, using useRef to store the equivalent of a class instance variable). Sure, in some ways hooks have made life easier (no more Higher-Order Components for context injection!), but at least HOCs were just functions, and celebrated as such by the React team.

In spite of the brevity of React’s documentation, the FAQ for hooks is as long as the FAQ for the rest of React.

Readability

We read a line of code many more times than we wrote it. For me, hooks don’t read cleanly. How do you know if an effect is running on its initial mount? Why is a function returned from useEffect? What's that array at the end of useEffect? Why is .current used for a non-DOM-or-component ref? These are all things you can learn, but they’re not immediately clear to anyone that hasn’t spent some time with hooks.

Many things are murky. Which variables are for state, or props, or instance variables? With classes you know by the preface (this.props, this.state, this.), but function components using hooks don’t have that structure.

Compare that to the class component componentDidUpdate(prevProps, prevState) lifecycle method. By looking at it, even without experience with React class components, I know roughly when it fires (after the component updated) and I know that it takes in the previous state and props.

Scalability

Facebook has mentioned React scaling well. At work we use React to a reasonable scale (we have hundreds of components). I don’t believe hooks scale well, at least not at the component level. They are great syntactic sugar for a small function component that needs a dash of state, but as the component grows or becomes more logical and less presentational, or has side-effects, the hooks don’t wear well.

I do not find hooks a good fit for complex, large components. When it comes to metrics for code cleanliness (cyclomatic complexity, lines of code in a function), they perform poorly, largely because with state management and after-render effects, function components are being asked to do more complex things than they were before. With class components, you could always decompose a large render method into multiple renderThisPart methods (even if you were technically supposed to make those their own components) and offset that burden, but with hooks… it’s all in one giant render method no matter how you slice it.

In the introduction to hooks, the React team mentioned a downside to class components -- “We’ve often had to maintain components that started out simple but grew into an unmanageable mess of stateful logic and side effects.” I don’t think that’s due to class components as much as it is due to the complexity of the domain. I’ve seen the same “unmanageable mess of stateful logic and side effects” with hooks already, and we’ve only been using them in earnest at work for 9 months. And I’d say the “unmanageable mess” is worse with hooks because the structure of classes gives you something to hold on to, instead of cramming everything into one giant function.

Where things really break down is the useEffect hook. On my team, we’ve had multiple issues with useEffect’s dependencies array. And this was code written by devs that were comfortable with React, just brand-new to hooks. Even the recommended linting rule needed turning off to avoid breaking the application with the autofixing of the dependencies array. Yes, these things can be solved by some hooks education and experience, and by mental guardrails. But can most of useEffect’s complexities be handled easily by componentDidMount and componentDidUpdate in a much more sustainable way? I believe so.

Example Time

Let’s take a few minutes and build a function-with-hooks component, compared with an existing class component. I’m not suggesting you rewrite class components in hooks, but this is the best way I know of illustrating the differences between using classes and using hooks, and also seeing what pain points there are when handling non-trivial use-cases with hooks. Pretend with me that we are familiar with React but new to hooks.

We're building a main panel of a chat application. Its responsibilities are to display a chat room's messages, both initial messages and those that come in during the course of the session. If the selected room changes, this component will show the new room's chats.

First, here's this component in class form:


  import React, { Component, createRef } from "react";
  import config from "../../config";
  import { injectUserContext } from "../../context/UserContext";
  import Message from "../Message";
  
  class MainTabPanel extends Component {
    constructor(props) {
      super(props);
  
      this.socket = undefined;
      this.listRef = undefined;
      this.state = {
        messages: [],      
      };
  
      this.listRef = createRef();
      this.createSocket();
    }
  
    async componentDidMount() {
      await this.fetchMessages();
      this.scrollToBottom();
    }
  
    async componentDidUpdate(prevProps) {
      if (this.props.roomId !== prevProps.roomId) {
        this.closeSocket();
        this.createSocket();
  
        await this.fetchMessages();
        this.scrollToBottom();
      }
    }
  
    render() {
      return (
        <div>
          <ul ref={this.listRef}>
            {this.state.messages.map((message) => {            
              return (
                <Message
                  key={message.id}
                  name={message.user.name}
                  time={message.timeSent}
                  thumbnail={message.user.thumbnail}
                  contents={message.contents}
                />
              );
            })}
          </ul>
          {/* message bar here */}        
        </div>
      );
    }
  
    fetchMessages = async () => {
      const messagesResponse = await fetch(
        `/rooms/${this.props.roomId}/messages`
      );
      const messageJson = await messagesResponse.json();
      this.setState({ messages: messageJson });
    };
  
    sendSocketData = (data) => {
      this.socket.send(JSON.stringify({ ...data, clientType: "message" }));
    };
  
    createSocket = () => {
      this.socket = new WebSocket(config.webSocketUrl);
  
      this.socket.addEventListener("open", () => {
        if (this.props.user) {
          this.sendSocketData({
            type: "register",
            data: { id: this.props.roomId },
          });
        }
      });
  
      this.socket.addEventListener("message", (event) => {
        const messages = this.state.messages.slice(0);      
        messages.push(JSON.parse(event.data));
        this.setState({
          messages,
        });
  
        this.scrollToBottom();
      });
    };
  
    closeSocket = () => {
      this.socket.close();
    };
  
    scrollToBottom = () => {
      if (this.listRef.current) {
        this.listRef.current.scroll(0, this.listRef.current.scrollHeight);
      }
    };
  }
  
  export default injectUserContext(MainTabPanel);
            

Let's build the same thing with hooks. First, we'll create a skeleton of the component:


  import React from "react";

  const MainTabPanel = (props) => {  
    return (
      <div>
        <ul>
          {/* some messages displayed here */}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Looks like we need to display some messages. To do that, we'll loop over messages (stored as state) and display them. We'll use useState for this:


- import React from 'react';
+ import React, { useState } from "react";
+ import Message from "../Message";

  const MainTabPanel = (props) => {  
+   // funky syntax, but ok
+   const [messages, setMessages] = useState([]);

    return (
      <div>
        <ul>               
+         {messages.map((message) => {            
+           return (
+             <Message
+               key={message.id}
+               name={message.user.name}
+               time={message.timeSent}
+               thumbnail={message.user.thumbnail}
+               contents={message.contents}
+             />
+           );
+         })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

So far so good, except we don't have any way of getting messages. Let's fetch some messages from our API endpoint when the component first mounts, using useEffect:


- import React, { useState } from "react";
+ import React, { useEffect, useState } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

+   // do this after the component is rendered... every time
+   useEffect(() => {
+     fetchMessages();
+   });
+
+   const fetchMessages = async () => {
+     const messagesResponse = await fetch(
+       `/rooms/${props.roomId}/messages`
+     );
+     const messageJson = await messagesResponse.json();
+     setMessages(messageJson);
+   };

    return (
      <div>
        <ul>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

We don't want that fetching to happen with every render, so we'll tell useEffect to only run it when the active room changes:


  import React, { useEffect, useState } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    useEffect(() => {
      fetchMessages();
+      // kind of magic array
-    });
+    }, [props.roomId]);

    const fetchMessages = async () => {
      const messagesResponse = await fetch(
        `/rooms/${props.roomId}/messages`
      );
      const messageJson = await messagesResponse.json();
      setMessages(messageJson);
    };

    return (
      <div>
        <ul>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

We start playing around with the new component and feel pretty good. Looking in React's docs, however, we learn that you should place a function called from useEffect inside of it, if it uses values that are in the useEffect's dependency array. We'll move fetchMessages:


  import React, { useEffect, useState } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    useEffect(() => {
+     // oh, so this needs to be defined IN the useEffect
+     const fetchMessages = async () => {
+       const messagesResponse = await fetch(
+         `/rooms/${props.roomId}/messages`
+       );
+       const messageJson = await messagesResponse.json();
+       setMessages(messageJson);
+     };
+
      fetchMessages();
      // kind of magic array
    }, [props.roomId]); 
    
-   const fetchMessages = async () => {
-     const messagesResponse = await fetch(
-       `/rooms/${props.roomId}/messages`
-     );
-     const messageJson = await messagesResponse.json();
-     setMessages(messageJson);
-   };

    return (
      <div>
        <ul>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

The chat messages are displayed with the most-recent message at the bottom. To start the user at the most-recent messages, we'll scroll the list to the bottom after fetching messages:


- import React, { useEffect, useState } from "react";
+ import React, { useEffect, useState, useRef } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

+   const listRef = useRef(null);

    useEffect(() => {
      // oh, so this needs to be defined IN the useEffect
      const fetchMessages = async () => {
        const messagesResponse = await fetch(
          `/rooms/${props.roomId}/messages`
        );
        const messageJson = await messagesResponse.json();
        setMessages(messageJson);
      };

+     fetchMessages();
      scrollToBottom();
      // kind of magic array
    }, [props.roomId]);  

+   const scrollToBottom = () => {
+     if (listRef.current) {
+       listRef.current.scroll(0, listRef.current.scrollHeight);
+     }
+   };

    return (
      <div>
-       <ul>
+       <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Oops dedupes, we'll want to make sure the messages have already returned before scrolling. Let's await:


  import React, { useEffect, useState, useRef } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);

-   useEffect(() => {
+   useEffect(async () => {
      // oh, so this needs to be defined IN the useEffect
      const fetchMessages = async () => {
        const messagesResponse = await fetch(
          `/rooms/${props.roomId}/messages`
        );
        const messageJson = await messagesResponse.json();
        setMessages(messageJson);
      };

+     // need to await this so scrollToBottom happens after
+     await fetchMessages();
      scrollToBottom();
      // kind of magic array
    }, [props.roomId]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

That didn't work. React says that the effect can't be async, so we'll create another function inside useEffect, make that async, and call it whenever the effect runs:


  import React, { useEffect, useState, useRef } from "react";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);

-   useEffect(async () => {
+   useEffect(() => {
+     // whew, this useEffect is getting quite nuanced
+     (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();
+     })();
      // kind of magic array
    }, [props.roomId]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Whew. Let's pause for a moment and look at what we've done. Already the effect we've created looks a little unwieldy, but we'll keep going and see if the use-cases force a refactor.

Because this is a live chat app, we need to update the messages whenever a new message is received. We'll use web sockets for this. Let's set things up:


  import React, { useEffect, useState, useRef } from "react";
+ import config from "../../config";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);
+   const socket = useRef();

    useEffect(() => {
      // whew, this useEffect is getting quite nuanced
      (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

+       closeSocket();
+       createSocket();

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();
      })();
      // kind of magic array
    }, [props.roomId]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

+   const createSocket = () => {
+     // .current? I thought that was only for DOM/component refs
+     socket.current = new WebSocket(config.webSocketUrl);
+
+     socket.current.addEventListener("open", () => {
+       // when the socket is ready      
+     });
+
+     socket.current.addEventListener("message", (event) => {
+       // when a message is received
+     });
+   };
+
+   const closeSocket = () => {
+     socket.current?.close();
+   };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

After googling, we realize that we can use useRef for the rough equivalent of a class instance variable. It looks a little funny, but it works. We've set up our web socket but aren't doing anything with it yet. Let's update our messages whenever we receive one from the socket:


  import React, { useEffect, useState, useRef } from "react";
  import config from "../../config";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);
    const socket = useRef();

    useEffect(() => {
      // whew, this useEffect is getting quite nuanced
      (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

        closeSocket();
        createSocket();

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();
      })();
      // kind of magic array
    }, [props.roomId]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

    const createSocket = () => {
      // .current? I thought that was only for DOM/component refs
      socket.current = new WebSocket(config.webSocketUrl);

      socket.current.addEventListener("open", () => {
        // when the socket is ready      
      });

      socket.current.addEventListener("message", (event) => {
+       const clonedMessages = messages.slice(0);      
+       clonedMessages.push(JSON.parse(event.data));
+       setMessages(clonedMessages);
+
+       scrollToBottom();
      });
    };

    const closeSocket = () => {
      socket.current?.close();
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Next, let's make sure the socket server knows that we are connected on the other end, letting it know what room we're viewing and which user we are. We'll be sure to only do this if there is a user.


- import React, { useEffect, useState, useRef } from "react";
+ import React, { useState, useEffect, useRef, useContext } from "react";
  import config from "../../config";
+ import UserContext from "../../context/UserContext";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);
    const socket = useRef();
+   // nice, so much easier than the HOC approach
+   const userContext = useContext(UserContext);

    useEffect(() => {
      // whew, this useEffect is getting quite nuanced
      (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

        closeSocket();
        createSocket();

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();
      })();
      // kind of magic array
    }, [props.roomId]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

    const createSocket = () => {
      // .current? I thought that was only for DOM/component refs
      socket.current = new WebSocket(config.webSocketUrl);

      socket.current.addEventListener("open", () => {
+       if (userContext.user) {
+         sendSocketData({
+           type: "register",
+           // oh no... this is props... needs to go in useEffect?
+           data: { 
+             roomId: props.roomId, 
+             userId: userContext.user.id 
+           },
+         });
+       }   
      });

      socket.current.addEventListener("message", (event) => {
+       // wait, state needs to go in useEffect too?
        const clonedMessages = messages.slice(0);      
        clonedMessages.push(JSON.parse(event.data));
        setMessages(clonedMessages);

        scrollToBottom();
      });
    };

+   const sendSocketData = (data) => {
+     socket.current?.send(JSON.stringify({ ...data, clientType: "message" }));
+   };

    const closeSocket = () => {
      socket.current?.close();
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Oh dear... we're calling functions from useEffect that use props.roomId, which is in the effect's dependency array. And looks our state and context used in these functions should probably be in the dependencies array as well so we don't get stale values. Here we go...


  import React, { useState, useEffect, useRef, useContext } from "react";
  import config from "../../config";
  import UserContext from "../../context/UserContext";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);
    const socket = useRef();
    // nice, so much easier than the HOC approach
    const userContext = useContext(UserContext);

    useEffect(() => {
      // whew, this useEffect is getting quite nuanced
      (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

+       // guess I'll put this whole thing here to be safe... 
+       // though holy cow, useEffect is like half of my component now
+       const createSocket = () => {
+         // .current? I thought that was only for DOM/component refs
+         socket.current = new WebSocket(config.webSocketUrl);
+      
+         socket.current.addEventListener("open", () => {
+           if (userContext.user) {
+             sendSocketData({
+               type: "register",              
+               data: { 
+                 roomId: props.roomId, 
+                 userId: userContext.user.id 
+               },
+             });
+           }   
+         });
+      
+         socket.current.addEventListener("message", (event) => {          
+           const clonedMessages = messages.slice(0);      
+           clonedMessages.push(JSON.parse(event.data));
+           setMessages(clonedMessages);
+      
+           scrollToBottom();
+         });
+       };

        closeSocket();
        createSocket();

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();
      })();
      // kind of magic array
+     // I guess I'm supposed to put context in here, didn't see it in the docs...
-   }, [props.roomId]);  
+   }, [props.roomId, messages, userContext]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

-   const createSocket = () => {
-     // .current? I thought that was only for DOM/component refs
-     socket.current = new WebSocket(config.webSocketUrl);
-      
-     socket.current.addEventListener("open", () => {
-       if (userContext.user) {
-         sendSocketData({
-           type: "register",              
-           data: { 
-             roomId: props.roomId,
-             userId: userContext.user.id
-           },
-         });
-       }   
-     });
-      
-     socket.current.addEventListener("message", (event) => {          
-       const clonedMessages = messages.slice(0);      
-       clonedMessages.push(JSON.parse(event.data));
-       setMessages(clonedMessages);
-      
-       scrollToBottom();
-     });
-   };

    const sendSocketData = (data) => {
      socket.current?.send(JSON.stringify({ ...data, clientType: "message" }));
    };

    const closeSocket = () => {
      socket.current?.close();
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Our effect is massive now. Way bigger than we're comfortable with. And since all these pieces rely on the change of props.roomId, there doesn't seem to be a good way to split the effect. My eyes hurt just looking at it, and it's sure to do poorly on some of the standard code complexity metrics.

Last of all, let's use the effect-provided way to clean up, so that we don't need to explicitly close the socket connection before creating a new one (it'll happen for us each time the effect is run):


  import React, { useState, useEffect, useRef, useContext } from "react";
  import config from "../../config";
  import UserContext from "../../context/UserContext";
  import Message from "../Message";

  const MainTabPanel = (props) => {  
    // funky syntax, but ok
    const [messages, setMessages] = useState([]);

    const listRef = useRef(null);
    const socket = useRef();
    // nice, so much easier than the HOC approach
    const userContext = useContext(UserContext);

    useEffect(() => {
      // whew, this useEffect is getting quite nuanced
      (async () => {
        // oh, so this needs to be defined IN the useEffect
        const fetchMessages = async () => {
          const messagesResponse = await fetch(
            `/rooms/${props.roomId}/messages`
          );
          const messageJson = await messagesResponse.json();
          setMessages(messageJson);
        };

        // guess I'll put this whole thing here to be safe... 
        // though holy cow, useEffect is like half of my component now
        const createSocket = () => {
          // .current? I thought that was only for DOM/component refs
          socket.current = new WebSocket(config.webSocketUrl);
      
          socket.current.addEventListener("open", () => {
            if (userContext.user) {
              sendSocketData({
                type: "register",              
                data: { 
                  roomId: props.roomId,
                  userId: userContext.user.id
                },
              });
            }   
          });
      
          socket.current.addEventListener("message", (event) => {          
            const clonedMessages = messages.slice(0);      
            clonedMessages.push(JSON.parse(event.data));
            setMessages(clonedMessages);
      
            scrollToBottom();
          });
        };

-       closeSocket();
        createSocket();

        // need to await this so scrollToBottom happens after
        await fetchMessages();
        scrollToBottom();

+       return () => {
+         // this will clean it up each time. 
+         // a little cryptic with returning a function
+         closeSocket();
+       }
      })();
      // kind of magic array
      // I guess I'm supposed to put context in here, didn't see it in the docs...
    }, [props.roomId, messages, userContext]);  

    const scrollToBottom = () => {
      if (listRef.current) {
        listRef.current.scroll(0, listRef.current.scrollHeight);
      }
    };

    const sendSocketData = (data) => {
      socket.current?.send(JSON.stringify(
        { ...data, clientType: "message" }
      ));
    };

    const closeSocket = () => {
      socket.current?.close();
    };

    return (
      <div>
        <ul ref={listRef}>
          {messages.map((message) => {            
            return (
              <Message
                key={message.id}
                name={message.user.name}
                time={message.timeSent}
                thumbnail={message.user.thumbnail}
                contents={message.contents}
              />
            );
          })}
        </ul>      
        {/* message bar here */}
      </div>
    );
  };

  export default MainTabPanel;
        

Ok. We've written the function-with-hooks equivalent of the class component we saw initially. For comparison, the class version is again included below. Scan over both versions and see which one seems simpler, more structured, and easy to reason about. Then put yourselves in a new-to-React mindset and scan them again.


  import React, { Component, createRef } from "react";
  import config from "../../config";
  import { injectUserContext } from "../../context/UserContext";
  import Message from "../Message";
  
  class MainTabPanel extends Component {
    constructor(props) {
      super(props);
  
      this.socket = undefined;
      this.listRef = undefined;
      this.state = {
        messages: [],      
      };
  
      this.listRef = createRef();
      this.createSocket();
    }
  
    async componentDidMount() {
      await this.fetchMessages();
      this.scrollToBottom();
    }
  
    async componentDidUpdate(prevProps) {
      if (this.props.roomId !== prevProps.roomId) {
        this.closeSocket();
        this.createSocket();
  
        await this.fetchMessages();
        this.scrollToBottom();
      }
    }
  
    render() {
      return (
        <div>
          <ul ref={this.listRef}>
            {this.state.messages.map((message) => {            
              return (
                <Message
                  key={message.id}
                  name={message.user.name}
                  time={message.timeSent}
                  thumbnail={message.user.thumbnail}
                  contents={message.contents}
                />
              );
            })}
          </ul>
          {/* message bar here */}        
        </div>
      );
    }
  
    fetchMessages = async () => {
      const messagesResponse = await fetch(
        `/rooms/${this.props.roomId}/messages`
      );
      const messageJson = await messagesResponse.json();
      this.setState({ messages: messageJson });
    };
  
    sendSocketData = (data) => {
      this.socket.send(JSON.stringify({ ...data, clientType: "message" }));
    };
  
    createSocket = () => {
      this.socket = new WebSocket(config.webSocketUrl);
  
      this.socket.addEventListener("open", () => {
        if (this.props.user) {
          this.sendSocketData({
            type: "register",
            data: { id: this.props.roomId },
          });
        }
      });
  
      this.socket.addEventListener("message", (event) => {
        const messages = this.state.messages.slice(0);      
        messages.push(JSON.parse(event.data));
        this.setState({
          messages,
        });
  
        this.scrollToBottom();
      });
    };
  
    closeSocket = () => {
      this.socket.close();
    };
  
    scrollToBottom = () => {
      if (this.listRef.current) {
        this.listRef.current.scroll(0, this.listRef.current.scrollHeight);
      }
    };
  }
  
  export default injectUserContext(MainTabPanel);
        

Abstraction That Stretches Too Far

As mentioned previously, I feel that hooks have introduced too many React/hooks-specific concepts, aren’t very readable, and don’t scale well when a component gets more complex. But at a deeper level, I feel the root problem is that hooks are an abstraction that has stretched too far.

It’s not that hooks haven’t been beneficial. useState is straightforward and, with one line, avoids the function-to-class rewrite. useContext is likewise helpful. But once you’ve added one hook, you’ve crossed a threshold that’s hard to walk back. The component is no longer a pure function. You see use-cases for other types of hooks, and the complexity of the component increases.

I think the awkwardness and gotchas come mainly because function-hook components are pretending to be something they’re not. They look like a function, but they are far less predictable. On the flip side, in spite of their state/refs/side-effects, they lack the structure to make those things sustainable. The component is trying to be two things at the same time, and doing neither of them well.

If the real problem is that a function component needs instance and state, it should have been a class component to begin with. One solution would be go back in time, uninvent function components, and erase some of the need for hooks. I love function components for pure, UI-only concerns, but I feel that having 2 ways of writing components has created some of this problem.

It’s interesting to see a reversal in the trend. A few years ago, things were swinging far in the functional, declarative direction, with state management as a major concern and redux as the dominant choice. Then people realized the indirection was painful, and struggled (myself included) with whether to have state be local or global. After that, the community decided storing state within the component wasn’t so bad after all (similar to the OO concept of colocating data with the object that uses it). Continuing that movement, with hooks we have an emphasis on making function components stateful.

So now we have stateful function components (or funclasses, if you prefer), that are neither here nor there, neither pure nor sturdy. We’ll see if time proves me wrong. Maybe I’ll prove myself wrong. But so far, I feel the conceptual issues with hooks outweigh the convenience they provide.