DEV Community

craig martin
craig martin

Posted on • Edited on

React Hooks are a More Accurate Implementation of the React Mental Model

React Functional Components using Hooks are a More Accurate Implementation of the React Mental Model for State and Effects, than React Classes

TL;DR React made updating the DOM declarative. Hooks made components themselves declarative.

The key of React was allowing declarative code to be mapped to an imperative DOM.

This was especially true of functional components, which would simply map data to an object describing the UI. React would take this object and surgically (imperatively) update the DOM.

However, with class components, while the render function was still declarative, the class instance itself (where the state lived) is mutable - which made it harder to reason about.

The implementation for state and side-effects were within these class components - tied to the mutating instance.

React hooks are a re-conception and re-implemenation of state and side-effects in React - an implementation instead of in class components, is in functional components. As a basic definition they are functions that let you "hook into" React state and lifecycle features. But the key is their implementation with functional components in a declarative api.

"But why is this a 'more accurate implementation of the react mental model'?"

React hooks allow components to be truly declarative even if they contain state and side-effects.

State is now retrieved declaratively without mutating the structure of the component (ie as the class instance would be).

Side-effects are now declaratively aligned with state, instead of with the component's mutation.

Just as the first key of react was a declarative mapper to the DOM, hooks are the second key: providing a declarative api in the component for state and side effects.

"Um, OK, sure.. How about some code?"

Lets look at two versions of doing the same thing. The first version uses the initial class-based implementation of state and effects, and second uses the new hook-based implementation.

The example is an (very contrived) User component. An input will search for the user and display their name, which can be edited and saved.

Using React's initial class-based implementation of state and effects

https://codesandbox.io/s/react-classes-are-the-wrong-mental-model-n9zbs

/*
 * A code sample to show how React class components are
 * not the best implementation of the react mental model.
 *
 * Limitations:
 * - 1. With react classes, `this` is mutable and harder
 *      to reason about
 * - 2. With react classes, the lifecyle hooks are aligned
 *      with the component instead of the data.
 *
 * To see 1: save a user's name, and then immediately
 * change it again. You'll see the confirmation alert has
 * the wrong name (the new one, not the one which was saved).
 * Because "this" is mutated before the save finishes,
 * the wrong data is surfaced to the user.
 *
 * To see 2: Notice how the code for componentDidUpdate
 * and componentDidMount is doing the same thing? What we
 * care about is changes to "username" data but instead
 * the model here is built around changes to the component.
 */

import React from "react";

class User extends React.Component {
  state = {
    username: this.props.username
  };

  handleUsernameChange = e => {
    this.setState({ username: e.target.value });
  };

  handleNameChange = e => {
    const name = e.target.value;
    this.setState(state => ({
      ...state,
      user: {
        ...state.user,
        name
      }
    }));
  };

  save = () => {
    // Pretend save that takes two seconds
    setTimeout(
      () => alert(`User's name has been saved to "${this.state.user.name}`),
      2000
    );
  };

  async fetchUser() {
    const response = await fetch(
      `https://api.github.com/users/${this.state.username}`
    );
    if (!response.ok) {
      return {};
    }
    return await response.json();
  }

  async componentDidMount() {
    if (this.props.username) {
      if (this.state.username) {
        const user = await this.fetchUser();
        this.setState({ user });
      }
    }
  }

  async componentDidUpdate(prevProps, prevState) {
    if (this.state.username !== prevState.username) {
      if (this.state.username) {
        const user = await this.fetchUser();
        this.setState({ user });
      }
    }
  }

  componentWillUnmount() {
    // clean up any lingering promises
  }

  render() {
    return (
      <>
        Search
        <input
          value={this.state.username || ""}
          placeholder="Github Username"
          onChange={this.handleUsernameChange}
        />
        <hr />
        {this.state.user && (
          <>
            <h2>Name</h2>
            <input
              value={this.state.user.name}
              onChange={this.handleNameChange}
            />
            <button onClick={this.save}>Save</button>
          </>
        )}
      </>
    );
  }
}

export default User;

Here is the live code running. You can see point 1 described in the code comment above: save a user's name, and then immediately change it again. You'll see the confirmation alert has the wrong name (the new one, not the one which was saved).

Now lets look at...

Using React's new hook-based implementation of state and effects

https://codesandbox.io/s/react-hooks-are-a-better-mental-model-f9kql

/*
 * A code sample to show how React functional components useing "hooks" are a
 * better implementation of the react mental model.
 */
import React, { useState, useEffect } from "react";

const fetchUser = async username => {
  if (!username) return await {};
  const response = await fetch(`https://api.github.com/users/${username}`);
  if (!response.ok) return {};
  return await response.json();
};

const saveUser = user => {
  // Pretend save that takes two seconds
  setTimeout(() => alert(`User's name has been saved to "${user.name}`), 2000);
};

export default ({ username: initialUsername = "" }) => {
  const [user, setUser] = useState({});
  const [username, setUsername] = useState(initialUsername);

  useEffect(() => {
    const doFetchAndSet = async () => {
      const u = await fetchUser(username);
      setUser(u);
    };
    doFetchAndSet();
  }, [username]);

  return (
    <>
      Search
      <input
        value={username || ""}
        placeholder="Github Username"
        onChange={e => setUsername(e.target.value)}
      />
      <hr />
      {user.name && (
        <>
          <h2>Name</h2>
          <input
            value={user.name}
            onChange={e => setUser({ ...user, name: e.target.value })}
          />
          <button onClick={() => saveUser(user)}>Save</button>
        </>
      )}
    </>
  );
};

Again, here is this live code running. If you try to reproduce the bug from the first example, you won't be able to.


What insights am I missing? What did I neglect or exaggerate? Let me know!

Top comments (2)

Collapse
 
gdeb profile image
Géry Debongnie

I believe that you are indeed exagerating. Hooks are wonderful, but you are trying too hard too make them look good.

My point is that yes, you are right in some way that react hooks are more declarative than class components, but only because react decided to cripple the hooks by not supporting class components

Weirdly, the React ecosystem decided that the proper reaction to that issue was to bash on class components for not being compatible with hooks instead of lamenting about the lack of class support for hooks.

But it is certainly possible to have hooks in class components (if the framework decides to implement it). And with those hooks, your two arguments against class would be nullified:

  1. the user state would be captured by the hook closure, so the component would not need to manage the saving operation
  2. the hook would register itself on the mounted/prop update lifecycle of the component, but with just one function without duplication, and properly isolated from the component.
Collapse
 
gdeb profile image
Géry Debongnie

by the way, sorry to react again to your posts. I just kind of enjoy the discussion about interesting points... Hope you don't take it as an attack or anything.