Why am I disappointed in hooks

Translation of the article was prepared on the eve of the start of the course “React.js Developer”


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 hooks

I often hear that hooks are created to replace class components. Sorry post “Introduction to Hooks”, published on the official React site, advertises this innovation, frankly, unsuccessfully:

Hooks are new in React 16.8 that allow 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 you to solve cross-cutting functionality issues 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 functions – stateless components.

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

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 useEffect It makes 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 the application on ember with this frenzy of observers and computed properties, I will always have a fond feeling for unidirectional data flow.

Hook problem useEffect and similar in that it is 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 already tired follow 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

Hook useEffect can take an optional second argument called an array of dependencies and allows you to callback the effect when you need it. To determine if a change has occurred, React compares the values ​​with each other using the Object.is… 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>;
};

IN line 14 into function useFetch a new object will be passed on 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 decision… Therefore, you need to use the hook carefully, otherwise serious problems with application performance can arise. I constantly think what can be done about it, and I have already found Several variants… For more dynamic objects, you will have to look for more workarounds.

there is eslint plugin for automatic error correctionfound 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 like use-deep-object-compare and use-memo-one, suggests that the problem (or at least confusion) really exists.

React relies on the order in which hooks are called

The earliest custom hooks were multiple implementations of the function useFetch for requests to the 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 is 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 hook useFetch will be called once on first render. In lines 35–38, we render the pagination buttons. But how would we call the hook useFetch from an event handler for these buttons?

The 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>

Hook call useFetch from an event handler violates the rules of hooks because the order in which they are called is changed on every 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. Plugin react-async-hook 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>
);

Hook call useAsyncCallback will return an object with expected properties “load”, “error” and “result”, 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 the function useAsyncTask

Here is a complete example with a simplified version useAsyncTask:
In the meantime, there is no need to know about it. ”

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 результатthat we expect. But the function also returns a function startwhich you can call later. Job created with function createTaskdoes not affect the update. The update is triggered by functions forceUpdate and forceUpdateRef in useAsyncTask

We now have a function startthat can be called from an event handler or other 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. Good thing the plugin react-hooks-async contains the function useAsyncRun – this makes the task 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;
  });
};

Function start will be executed when 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 the hook useFetch at the beginning of the functional component. Function useAsyncRun calls the API at the very beginning, and the function start we use in the handler onClick for pagination buttons.

Now hook useFetch can be used for its intended purpose, but, unfortunately, you have to go by roundabout ways. 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-based 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 for free

Conclusion

I think the example with useFetch best 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. Solution with useFetch rather complicated. In addition, when using hooks, you cannot do without closures. Closures are continuous surprises that have left many scars in my soul.

Closures (like those passed to useEffect and useCallback) 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. A lot has accumulated on Stack Overflow questions obsolete in hook useEffect and the like. I have wrapped functions in useCallback and twisted the dependency arrays this way and that to get rid of the problem with stale state or infinite repetition of the render. 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:

  • Scaling a Redux application with ducks

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *