Paul Cowan Contract software developer

Frustrations with React Hooks

7 min read 2233

What problems do Hooks solve?

Before I detail my current frustrations with Hooks I do want to state for the record that I am, for the most part, a fan of Hooks.

I often hear that the main reason for the existence of Hooks is to replace class components. Sadly, the main heading in the official React site’s post introducing Hooks really undersells Hooks with this not-so-bold statement:

Hooks are a new addition in React 16.8. They let you use state, and other React features without writing a class.

This explanation does not give me a lot of motivation to use Hooks apart from “classes are not cool, man”! For my money, Hooks allow us to address cross-cutting concerns in a much more elegant way than the previous patterns such as mixins, higher-order components and render props.

Functionality like logging and authentication are not component-specific, and Hooks allow us to attach this type of reusable behavior to components.

What was wrong with class components?

There is something beautiful and pure about the notion of a stateless component that takes some props and returns a React element. It is a pure function and as such, side effect free.

export const Heading: React.FC<HeadingProps> = ({ level, className, tabIndex, children, ...rest }) => {
  const Tag = `h${level}` as Taggable;

  return (
    <Tag className={cs(className)} {...rest} tabIndex={tabIndex}>
      {children}
    </Tag>
  );
};

Unfortunately, the lack of side effects makes these stateless components a bit limited, and in the end, something somewhere must manipulate state. In React, this generally meant that side effects are added to stateful class components. These class components, often called container components, execute the side effects and pass props down to these pure stateless component functions.

There are several well-documented problems with the class-based lifecycle events. One of the biggest complaints is that you often have to repeat logic in componentDidMount and componentDidUpdate.

async componentDidMount() {
  const response = await get(`/users`);
  this.setState({ users: response.data });
};

async componentDidUpdate(prevProps) {
  if (prevProps.resource !== this.props.resource) {
    const response = await get(`/users`);
    this.setState({ users: response.data });
  }
};

If you have used React for any length of time, you will have encountered this problem.

With Hooks, this side effect code can be handled in one place using the effect Hook.

const UsersContainer: React.FC = () => {
  const [ users, setUsers ] = useState([]);
  const [ showDetails, setShowDetails ] = useState(false);

 const fetchUsers = async () => {
   const response = await get('/users');
   setUsers(response.data);
 };

 useEffect( () => {
    fetchUsers(users)
  }, [ users ]
 );

 // etc.

The useEffect Hook is a considerable improvement, but this is a big step away from the pure stateless functions we previously had. Which brings me to my first frustration.

This is yet another JavaScript paradigm to learn

For the record, I am a 49-year-old React fanboy. The one-way data flow will always have a place in my heart after working on an ember application with the insanity of observers and computed properties.

The problem with useEffect and friends is that it exists nowhere else in the JavaScript landscape. It is unusual and has quirks, and the only way for me to become proficient and iron out these quirks is to use it in the real world and experience some pain. No tutorial using counters is going to get me into the flow. I am a freelancer and use other frameworks apart from React, and this gives me fatigue. The fact that I need to set up the eslint-plugin-react-hooks to keep me on the straight and narrow for this specific paradigm does make me feel a bit wary.

To hell and back with the dependencies array

The useEffect Hook can take an optional second argument called the dependencies array which allows you to optimize when React would execute the effect callback. React will make a comparison between each of the values via Object.is to determine whether anything has changed. If any of the elements are different than the last render cycle, then the effect will be run against the new values.

The comparison works great for primitive JavaScript types, but the problems can arise if one of the elements is an object or an array. Object.is will compare objects and arrays by reference, and there is no way to override this functionality and supply a custom comparator.

Reference checking objects by reference is a common gotcha, and I can illustrate this with the following scaled-down version of a problem I encountered:

const useFetch = (config: ApiOptions) => {
  const  [data, setData] = useState(null);

  useEffect(() => {
    const { url, skip, take } = config;
    const resource = `${url}?$skip=${skip}&take=${take}`;
    axios({ url: resource }).then(response => setData(response.data));
  }, [config]); // <-- will fetch on each render

  return data;
};

const App: React.FC = () => {
  const data = useFetch({ url: "/users", take: 10, skip: 0 });
  return <div>{data.map(d => <div>{d})}</div>;
};

On line 14, a new object is passed into useFetch on each render if we do not do something to ensure the same object is used each time. In this scenario, it would be preferable to check this object’s fields and not the object reference.

I do understand why React has not gone down the route of doing deep object compares like we may see on things like use-deep-object-compare. You can get into some serious performance problems if not careful. I do seem to revisit this problem a lot, and there are a number of fixes for this. The more dynamic your objects are the more workarounds you start adding.

There is an eslint plugin that you really should be using with the automatic –fix setup in your text editor of choice to apply eslint fixes automatically. I do worry about any new feature that requires an external plugin to check correctness.

The fact that use-deep-object-compare, use-memo-one and others exist is a testimony to this being a common enough problem or at the very least, a point of confusion.

React relies on the order in which Hooks are called

Some of the first custom Hooks to hit the shelves were several useFetch implementations that use Hooks to query a remote API. Most skirt around the issue of calling the remote API from an event handler because Hooks can only be called from the start of a functional component.

What if the data we have has pagination links and we want to re-run the effect when the user clicks a link? Below is a simple useFetch example:

const useFetch = (config: ApiOptions): [User[], boolean] => {
  const [data, setData] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const { skip, take } = config;

    api({ skip, take }).then(response => {
      setData(response);
      setLoading(false);
    });
  }, [config]);

  return [data, loading];
};

const App: React.FC = () => {
  const [currentPage, setCurrentPage] = useState<ApiOptions>({
    take: 10,
    skip: 0
  });

  const [users, loading] = useFetch(currentPage);

  if (loading) {
    return <div>loading....</div>;
  }

  return (
    <>
      {users.map((u: User) => (
        <div>{u.name}</div>
      ))}
      <ul>
        {[...Array(4).keys()].map((n: number) => (
          <li>
            <button onClick={() => console.log('what do we do now?')}>{n + 1}</button>
          </li>
        ))}
      </ul>
    </>
  );
};

On line 23, the useFetch Hook will be called once on the first render. On lines 35 – 38, pagination buttons are rendered but how would we call the useFetch Hook from the event handlers of these buttons?

The rules of Hooks clearly state:

Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function.

Hooks need to be called in the same order each time the component renders. There are several reasons why this is the case which is beautifully articulated in this post.

You definitely cannot do this:

<button onClick={() => useFetch({ skip: n + 1 * 10, take: 10 })}>
  {n + 1}
</button>

Calling the useFetch Hook from an even handler breaks the rules of Hooks because you would break the order in which the Hooks are called on each render.

Return an executable function from the Hook

I have seen two solutions (that I like) to this problem which both follow the same theme. There is react-async-hook which returns an execute function from the Hook:

import { useAsyncCallback } from 'react-async-hook';

const AppButton = ({ onClick, children }) => {
  const asyncOnClick = useAsyncCallback(onClick);
  return (
    <button onClick={asyncOnClick.execute} disabled={asyncOnClick.loading}>
      {asyncOnClick.loading ? '...' : children}
    </button>
  );
};

const CreateTodoButton = () => (
  <AppButton
    onClick={async () => {
      await createTodoAPI('new todo text');
    }}
  >
    Create Todo
  </AppButton>
);

The call to the useAsyncCallback Hook will return an object that has the expected loading, error and result properties along with an execute function that we can call in the event handler.

react-hooks-async takes a slightly similar approach with its useAsyncTask function.

Here is a complete example with a scaled-down version of useAsyncTask below:

const createTask = (func, forceUpdateRef) => {
  const task = {
    start: async (...args) => {
      task.loading = true;
      task.result = null;
      forceUpdateRef.current(func);
      try {
        task.result = await func(...args);
      } catch (e) {
        task.error = e;
      }
      task.loading = false;
      forceUpdateRef.current(func);
    },
    loading: false,
    result: null,
    error: undefined
  };
  return task;
};

export const useAsyncTask = (func) => {
  const forceUpdate = useForceUpdate();
  const forceUpdateRef = useRef(forceUpdate);
  const task = useMemo(() => createTask(func, forceUpdateRef), [func]);

  useEffect(() => {
    forceUpdateRef.current = f => {
      if (f === func) {
        forceUpdate({});
      }
    };
    const cleanup = () => {
      forceUpdateRef.current = () => null;
    };
    return cleanup;
  }, [func, forceUpdate]);

  return useMemo(
    () => ({
      start: task.start,
      loading: task.loading,
      error: task.error,
      result: task.result
    }),
    [task.start, task.loading, task.error, task.result]
  );
};

The createTask function returns a task object with this interface:

interface Task {
  start: (...args: any[]) => Promise<void>;
  loading: boolean;
  result: null;
  error: undefined;
}

The task has the loading, error and result states that we would expect but it also returns a start function that we can call at a later date.

A task created by createTask does not trigger an update so forceUpdate and forceUpdateRef in useAsyncTask trigger the update instead.

We now have a start function that we can call from an event handler or at least from somewhere else apart from the very start of a functional component.

But now we have lost the ability to call our Hook when the functional component has first run. Thankfully react-hooks-async comes with a useAsyncRun function to facilitate this:

export const useAsyncRun = (
  asyncTask: ReturnType<typeof useAsyncTask>,
  ...args: any[]
) => {
  const { start } = asyncTask;
  useEffect(() => {
    start(...args);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [asyncTask.start, ...args]);
  useEffect(() => {
    const cleanup = () => {
      // clean up code here
    };
    return cleanup;
  });
};

The start function will be executed when any of the args arguments change.

The usage of the Hook now looks like this:

const App: React.FC = () => {
  const asyncTask = useFetch(initialPage);
  useAsyncRun(asyncTask);

  const { start, loading, result: users } = asyncTask;

  if (loading) {
    return <div>loading....</div>;
  }

  return (
    <>
      {(users || []).map((u: User) => (
        <div>{u.name}</div>
      ))}

      <ul>
        {[...Array(4).keys()].map((n: number) => (
          <li key={n}>
            <button onClick={() => start({ skip: 10 * n, take: 10 })}>
              {n + 1}
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};

The useFetch Hook is called at the start of the functional component in keeping with the laws of Hooks. The useAsyncRun function takes care of calling the API initially and the start function can be used in the onClick handler of the pagination buttons.

The useFetch Hook is now fit for purpose, but unfortunately, the complexity has risen. We have also introduced a closure which makes me slightly scared.

Conclusion

I think this useFetch example is an excellent example of my current frustrations with Hooks.

I do feel we are jumping through a few unexpected hoops for an elegant result, and I do understand why the call order of Hooks is essential. Unfortunately only having Hooks callable at the start of a functional component is limiting and I think we will still be scratching for ways around this. The useFetch solution is complex and Hooks also force you into working with closures, and I have many scars from unexpected things happening when working with closures.

Closures (like the ones passed to useEffect and useCallback) can capture old versions of props and state values. In particular, this happens if the “inputs” array is inadvertently missing one of the captured variables; this can be confusing.

Stale state due to code executing in a closure is one of the problems the Hooks linter sets out to cure. Stack Overflow has many questions about stale state in useEffect and friends. I seem to have spent an excessive amount of time twiddling various dependency arrays and wrapping functions in useCallback to avoid the stale state or infinite re-rendering. I understand why it is necessary, but it feels annoying, and there is no substitute for just going through a real-world problem to earn your stripes.

I mentioned at the beginning of the post that I am mostly in favor of Hooks, but they are deceptively complex. You will not find anything similar anywhere else on the JavaScript landscape. Having Hooks callable in every render of a functional component introduces issues that regular mixins do not. The need for a linter to enforce the pattern does make me wary, and the need for working with closures is always something that will introduce issues.

I would love to be proven wrong about this so please tell me how I have got Hooks wrong in the comments below.

 

Plug: , a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Paul Cowan Contract software developer

26 Replies to “Frustrations with React Hooks”

  1. Firstly, thanks for the article, it’s contentful as always.

    But let me try to give a different perspective because hooks design is dear to my heart.

    The main problem I see with this usage of useFetch is that you seem to perceive hook as a service. But there is a profound difference between those.

    Be it ordinary or custom, – hook is still an extension of your component behavior. Or at least it’s supposed to be one. You are literally “hooking” into your component lifecycle stages via the hooks abstraction. So you don’t control the hook end to end all the time. Every hook has mount and update phases aligning with the natural component lifecycles. So it’s all reactionary.

    Services, on the other hand, are entirely controllable. You initiate the service, and you get the result any time you want. That’s why calling a hook from an event callback to me seems unnatural and breaks uniflow. Because you cannot control the component lifecycle directly. You cannot start a useEffect on demand. The same way you cannot call componentDidMount directly from an event callback. You need to initiate something that will cause the component to change its state and trigger effect callbacks eventually.

    So in this sense, hooks are perfectly aligned with the general component unidirectional flow that existed before hooks.

    Now about the shallow comparison of config.

    You should treat config object the same way regardless of whether it’s a hook or not.
    Let’s you don’t have hooks how would you go about reasoning about this useFetch in a pre hook setup

    I guess it could’ve been something like this

    “`
    import React from “react”;
    import ReactDOM from “react-dom”;

    import “./styles.css”;

    const axios = ({ url }) =>
    Promise.resolve({
    data: [
    { id: 1, url },
    { id: 2, url },
    { id: 3, url },
    { id: 4, url },
    { id: 5, url }
    ]
    });

    class UseFetchComponent extends React.PureComponent {
    state = {
    data: null
    };

    handleFetch() {
    const { url, skip, take } = this.props.config;
    const resource = `${url}?$skip=${skip}&take=${take}`;
    axios({ url: resource }).then(response =>
    this.setState({ data: response.data })
    );
    }

    componentDidMount() {
    this.handleFetch();
    }

    componentDidUpdate(prevProps) {
    if (prevProps.config !== this.props.config) {
    this.handleFetch();
    }
    }

    render() {
    const { data } = this.state;
    console.log(this.props.config);

    if (data) {
    return data.map(d => {JSON.stringify(d)});
    }

    return null;
    }
    }

    class App extends React.Component {
    state = {
    config: { url: “test”, skip: 20, take: 10 }
    };

    handleSetRandomConfig = () => {
    this.setState(prevState => ({
    config: {
    …prevState.config,
    skip: (Math.random() * 100) | 0,
    take: (Math.random() * 100) | 0
    }
    }));
    };

    render() {
    return (

    Set random config

    );
    }
    }

    const rootElement = document.getElementById(“root”);
    ReactDOM.render(, rootElement);
    “`

    In which case config is stored in some outer state and updated immutably, every time the prop changes, the object identity changes, thus you can rely on reference checks. To me this is good practice, and it sets you up on the path to the pit of success.

    Let me know if this has been helpful.

  2. I agree, the reason ostensibly was because `this` issues are hard to understand for noobs and a frequent question on SO. However understanding `this` is really not that hard and as long as you use arrow methods for events and nested function callback you will rarely run into problems.

    So they traded this issue for hugely increasing their API surface area and adding some really none trivial concepts like stale data/mem leaks in closures if depedencies aren’t listed. Sub optimal rendering through all functions being created and breaking any pure checks by default. Infinite loops if your hooks both reads and writes to a state value, since you depend on it but you also update it. Added complexity when using things like debounce. And scaling issues since you cannot break down a component as hooks cannot appear out of order as you mentioned.

    The benefit I see is that hooks are cleaner looking than multiple wrapping HOCs, but I don’t think the trade offs were worth it.

  3. I am curious if you have tried pushing side-effects out to a redux middleware library (and keeping the state in a redux store), and if so, how that compares to some of these effects/api calls in react hooks? I cannot say that I am huge fan of react hooks beyond updating trivial local state, though I have not had much experience with them.

    1. I’ve done a lot of redux and I think this is partly why hooks exist. Things like loading and error states should not be in the global state but end up there in redux. Hooks definitely are an answer to this.

  4. The problem is that we’re essentially writing what ought to be pure functions, a la A => B, and attempting to add to them effectful behavior, without changing their type. In reality, once we had effectful behavior to a component their type is more like A => F[B]. This is the reality we should work with. If we have multiple effects, the type is still A => F[F[B]]. Thankfully, if we do things right, we should be able to compose effects! I agree with you, Paul. To me, React’s hook system feels like kludgy.

  5. I seems most of your concerns can be solved by destructuring props and using the properties in your dependency array.

    The dependency array is a declarative representation of what causes the effect to run. So this includes mount and any changes after… simply use “useState” and “useEffect” as intended in concert with destructured properties and the dependency array and you can easily run effect when and as desired.

    https://codesandbox.io/embed/hardcore-shtern-m0ui3

  6. I skirt around the problem of stale state by setting a ref at the top of the function that contains whatever the props are that render. Since ref references don’t change from render to render, referencing the ref-contained props inside my useEffect or useCallback always returns the most up to date props. That also means that the dependency arrays for these functions are always empty, the functions only ever created once, which prevents additional unnecessary rerendering of children due to changed function references. The only requirement is that the functions I use inside useEffect and friends have to use props through the ref reference.

    Effectively, I created a managed “this” variable.

  7. Few other options:
    1. use different hook: say Apollo provides both `useQuery` that executes immediately and `useLazyQuery` that will return “runner” inside array result. So you will be able to initiate request later(probably someone already created lazy version of useFetch).
    2. move fetching into separate component. Not sure it looks fine but Apollo provided “ and “ component to use them like `{someFlag && <Mutation …` and people used them that way.

  8. maybe a beginner here but i dont understand why for this usefetch example
    we cant memoize the data using usecallback / usememo and then change the state (current pagination) accordingly.
    then we would fetch only if needed?

  9. It’s funny how you had the right code all along, just didn’t used it.

    The key is “setCurrentPage”.

    setCurrentPage({ skip: 10 * n, take: 10 })}>

    Nice and simple, everything works with no complicated “solutions” (sorry, I couldn’t follow the overly complicated solutions that followed).

  10. Here is a codesandbox with the incredibly easy and elegant solution:

    https://codesandbox.io/s/dependency-array-v2ehx

    Hooks take a bit of getting used to.
    In the beginning I thought they complicate the code unnecessarily.
    Until I realized they can just be extracted into custom hooks.
    The real power of hooks, besides the fact they are declarative, is composition and by means of composition they can be abstracted away as custom hooks.
    React is all about declarative UI composition but lacked the “primitive” for having stateful logic composition in an elegant and declarative way. Until hooks.

  11. This example is probably the best I’ve seen so far, to interests me in adopting hooks. 👍

    Thanks!

  12. I have been using React for more than four years and I’m considering switching to something else because of Hooks. At first I thought I would just not use them, but everyone in the React ecosystem seems to be adopting them.
    Hooks are so wrong! I do not even want to spend time and energy to to argue against them.
    So long React!

  13. > I have been using React for more than four years and I’m considering switching to something else because of Hooks.

    I’m in the same boat. I spent 2 days this week trying to get on board with them.

    The re-usability they provide is super attractive, but the comprehension when reading the code, and the pitfalls are a huge turn off. Having to use closures and `useCallback` to avoid redefining event handlers on every render looks awful.

    I also have no interest in switching to `useReducer` and making magic string, switch statements everywhere. I’ve avoided Redux for the last 4 years over that as well.

    I feel like I’ve invested 4 years of my time in the wrong technology and I’m ready to find the next thing.

    Between the original mixins debacle and moving to classes, and now this; I don’t trust the React maintainers to give me a solid technology to build a project that will last more than 2 years.

  14. > what magic string do you mean?

    On the `useReducer` example:
    https://reactjs.org/docs/hooks-reference.html#usereducer

    I don’t want to pass around `increment` and `decrement` strings, and build switch statements to handle them.

    Typescript can help eliminate the magic string issue, but still not a fan of switch statements. Would rather have concrete functions in a “store” to call.

    “`
    const initialState = {count: 0};

    function reducer(state, action) {
    switch (action.type) {
    case ‘increment’:
    return {count: state.count + 1};
    case ‘decrement’:
    return {count: state.count – 1};
    default:
    throw new Error();
    }
    }

    function Counter() {
    const [state, dispatch] = useReducer(reducer, initialState);
    return (

    Count: {state.count}
    dispatch({type: ‘increment’})}>+
    dispatch({type: ‘decrement’})}>-

    );
    }
    “`

  15. Actually, the same as with “regular redux” you may use constants for Action Type. This demo sample just concentrates on different things.

  16. > Actually, the same as with “regular redux” you may use constants for Action Type. This demo sample just concentrates on different things.

    Right, but that’s a bunch of boilerplate I don’t want to write.

    I use Mobx so I can call actual functions on a class instead of passing strings/constants to giant switch statements.

Leave a Reply