Why am I disappointed in hooks

The translation of the article was prepared in anticipation of the start of the "React.js Developer" course .










How are hooks useful?



Before I tell you what and why I was disappointed, I want to officially declare that, in fact, I am a fan of hooks .



I often hear that hooks are created to replace class components. Unfortunately, the "Introduction to Hooks" post on the official React site advertises this innovation, frankly, unfortunate:



Hooks are an innovation in React 16.8 that allows you to use state and other React features without writing classes.



The message that I see here goes something like this: "Classes are not cool!" Not enough to motivate you to use hooks. In my opinion, hooks allow us to solve cross-cutting functionality more elegantly than previous approaches: mixins , higher-order components, and render props .



Logging and authentication functions are common to all components, and hooks allow you to attach such reusable functions to components.



What's wrong with class components?



There is some incomprehensible beauty in a stateless component (i.e. a component without an internal state) that takes props as input and returns a React element. This is a pure function, that is, a function without side effects.



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 limits the use of stateless components. After all, state manipulation is essential. In React, this means side effects are added to class beans that are stateful. They are also called container components. They perform side effects and pass props to pure stateless functions.



There are some well-known issues with class-based lifecycle events. Many people are unhappy that they have to repeat the logic in the methods componentDidMountand 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 });
  }
};


Sooner or later, all developers are faced with this problem.



This side effect code can be executed in a single component using an 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.


Hook useEffectmakes life much easier, but it deprives of that pure function - stateless component - that we used before. This is the first thing that disappointed me.



Another JavaScript paradigm to know



I am 49 years old and I am a fan of React. After developing an ember app with this observer and computed property madness, I will always have a warm feeling for unidirectional data flow.



The problem with hooks useEffectand the like is that they're not used anywhere else in the JavaScript landscape. He is unusual and generally weird. I see only one way to tame it - to use this hook in practice and suffer. And no examples of counters will induce me to selflessly code all night long. I am a freelancer and I use not only React but other libraries as well, and I am already tiredfollow all these innovations. As soon as I think that I need to install the eslint plugin, which will set me on the right path, this new paradigm starts to strain me.



Dependency arrays are hell



The useEffect hook can take an optional second argument called a dependency array , which allows you to call back the effect when you need it. To determine if a change has occurred , React compares the values ​​with each other using the Object.is method . If any elements have changed since the last render cycle, the effect will be applied to the new values.



Comparison is great for handling primitive data types. But if one of the elements is an object or an array, problems can arise. Object.is compares objects and arrays by reference and you can't do anything about it. The custom comparison algorithm cannot be applied.



Validating objects by reference is a known stumbling block. Let's take a look at a simplified version of a problem I recently 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 useFetchnew object will be passed to the function for each render, unless we make it so that the same object is used every time. In this scenario, you would want to check the fields of the object, not the reference to it.



I understand why React doesn't do deep object comparisons like this solution . Therefore, you need to use the hook carefully, otherwise serious problems with application performance can arise. I am constantly wondering what can be done about it, and have already found several options . For more dynamic objects, you will have to look for more workarounds.



There is an eslint plugin to fix errors automaticallyfound during code validation. It is suitable for any text editor. To be honest, I am annoyed by all these new features that require installing an external plugin to test them.



The very existence of plugins such as use-deep-object-compare and use-memo-one suggests that there really is a problem (or at least a mess).



React relies on the order in which hooks are called



The earliest custom hooks were multiple implementations of a function useFetchfor making requests to a remote API. Most of them do not solve the problem of making remote API requests from an event handler, because hooks can only be used at the beginning of a functional component.



But what if there are links to paginated sites in the data, and we want to rerun the effect when the user clicks the link? Here's a simple use case useFetch:



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(' ?')}>{n + 1}</button>
          </li>
        ))}
      </ul>
    </>
  );
};


On line 23, the hook useFetchwill be called once on the first render. In lines 35–38, we render the pagination buttons. But how would we call the hook useFetchfrom the event handler for these buttons?



Hook rules clearly state:



Don't use hooks inside loops, conditionals, or nested functions; instead, always use hooks only at the top level of React functions.



Hooks are called in the same order every time the component is rendered. There are several reasons for this, which you can learn about from this excellent post.



You can't do this:



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


Calling a hook useFetchfrom an event handler violates the rules of hooks, because the order of their call changes with each render.



Returning an executable function from a hook



I am familiar with two solutions to this problem. They take the same approach and I like both. The react-async-hook plugin returns a function from the hook execute:



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>
);


Calling the hook useAsyncCallbackwill return an object with the expected load, error, and result properties, as well as a function executethat can be called from an event handler.



React-hooks-async is a plugin with a similar approach. It uses a function useAsyncTask.



Here's a complete example with a simplified version useAsyncTask:

ο»Ώ

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 in the following form.



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


The job has states , and , which we expect. But the function also returns a function startthat can be called later. The job created with the function createTaskdoes not affect the update. The update is triggered by functions forceUpdateand forceUpdateRefin useAsyncTask.



We now have a function startthat can be called from an event handler or from another piece of code, not necessarily from the beginning of a functional component.



But we lost the ability to call the hook on the first run of the functional component. It's good that the react-hooks-async plugin contains a function useAsyncRun- this makes things easier:



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 = () => {
      //   
    };
    return cleanup;
  });
};


The function startwill be executed whenever any of the arguments changes args. Now the code with hooks 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>
    </>
  );
};


According to the rules of hooks, we use a hook useFetchat the beginning of a functional component. The function useAsyncRuncalls the API at the very beginning, and startwe use the function in the handler onClickfor the pagination buttons.



Now the hook useFetchcan be used for its intended purpose, but, unfortunately, you have to go around the wrong way. We also use a closure, which, I must admit, scares me a little.



Controlling hooks in application programs



In application programs, everything should work as intended. If you plan to track component-related issues AND user interactions with specific components, you can use LogRocket .







LogRocket is a kind of web application video recorder that records almost everything that happens on the site. The LogRocket plugin for React allows you to find user sessions during which the user clicked on a specific component of your application. You will understand how users interact with components and why some components do not render anything.



LogRocket records all actions and states from the Redux store. It is a set of tools for your application that allows you to record requests / responses with headers and bodies. They write HTML and CSS on the page, providing pixel-by-pixel rendering for even the most complex single page applications.



LogRocket offers a modern approach to debugging React applications - try it for free .



Conclusion



I think the c example useFetchbest explains why I'm frustrated with hooks.



Achieving the desired result turned out to be not as easy as I expected, but I still understand why it is so important to use hooks in a specific order. Unfortunately, our capabilities are severely limited due to the fact that hooks can only be called at the beginning of a functional component, and we will have to look further for workarounds. The solution is useFetchrather complicated. In addition, when using hooks, you cannot do without closures. Closures are continuous surprises that have left many scars in my soul.



Closures (such as those passed to useEffectanduseCallback) can grab older versions of props and state values. This happens, for example, when one of the captured variables is missing in the input array for some reason - difficulties can arise.



The obsolete state that occurs after executing code in a closure is one of the problems that the hook linter is designed to solve. There are a lot of questions on Stack Overflow about obsolete hooks useEffectand the like. I've wrapped functions in useCallbackand twisted the dependency arrays this way and that to get rid of the stale state or infinite repetition issue. It can't be otherwise, but it's a little annoying. This is a real problem that you have to solve to prove your worth.



At the beginning of this post, I said that in general I like hooks. But they seem very complicated. There is nothing quite like it in the current JavaScript landscape. Calling hooks every time a functional component is rendered creates problems that mixins do not. The need for a linter to use this pattern is not very credible, and closures are a problem.



I hope I just misunderstood this approach. If so, write about it in the comments.





Read more:






All Articles