Having mastered the hooks, many React-developers have experienced euphoria, finally getting a simple and convenient toolkit that allows them to implement tasks with significantly less code. But does this mean that the standard useState and useReducer hooks offered out of the box are all we need to manage the state?
In my opinion, in raw form, their use is not very convenient, they can rather be regarded as the basis for the construction of really convenient state control hooks. React developers themselves strongly encourage the development of custom hooks, so why not do it? Under the cut, we will consider a very simple and clear example of what is wrong with ordinary hooks and how they can be improved, so much so that we can completely abandon their use in their pure form.
There is a certain field for input, conditionally, a name. And there is a button, by clicking on which we must make a request to the server with the entered name (some kind of search). It would seem, what could be easier? However, the solution is far from obvious. First naive implementation:
const App = () => { const [name, setName] = useState(''); const [request, setRequest] = useState(); const [result, setResult] = useState(); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => setRequest(name)}/> { result && <div>Result: { result }</div> } </div>; }
What is wrong here? If the user, having entered something in the field, sends the form twice, only the first request will work for us, since the second click will not change the request and useEffect will not work. If we imagine that our application is a ticket search service, and the user may very well send the form over and over again without any changes, then such an implementation will not work for us! Using name as a dependency for useEffect is also unacceptable, otherwise the form will be sent immediately when the text changes. Well, you have to be creative.
const App = () => { const [name, setName] = useState(''); const [request, setRequest] = useState(); const [result, setResult] = useState(); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => setRequest(!request)}/> { result && <div>Result: { result }</div> } </div>; }
Now, with each click, we will change the request values ​​to the opposite, and we will achieve the desired behavior. This is a very small and innocent crutch, but it makes the code somewhat confusing to understand. Perhaps, now it seems to you that I suck the problem out of my finger and inflate its scales. Well, to answer this or not, you need to compare this code with other implementations that offer a more expressive approach.
Let's look at this example on a theoretical level, using the abstraction of threads. It is very convenient for describing the state of user interfaces. So, we have two streams: the data entered in the text field (name $), and the flow of clicks on the form submit button (click $). Of these, we need to create a third, combined stream of requests to the server.
name$ __(C)____(Ca)_____(Car)____________________(Carl)___________ click$ ___________________________()______()________________()_____ request$ ___________________________(Car)___(Car)_____________(Carl)_
Here is the behavior we need to achieve. Each flow has two aspects: the value it has, and the point in time at which values ​​flow through it. In various situations, we may need one or another aspect, or both. You can compare it with the rhythm and harmony in the music. Streams for which only the time of their triggering is significant is also called signals.
In our case, click $ is a pure signal: it doesn’t matter what values ​​flow through it (undefined / true / Event / whatever), it only matters when it happens. Case name $
the opposite: its changes do not entail any changes in the system, but we may need its value at some point. And from these two streams we need to make the third, taking from the first - time, from the second - meaning.
In the case of Rxjs, we have an almost ready operator for this:
const names$ = fromEvent(...); const click$ = fromEvent(...); const request$ = click$.pipe(withLatestFrom(name$), map(([name]) => fromPromise(fetch(...))));
However, the practical use of Rx in React can be quite inconvenient. A more suitable option is the mrr library, built on the same functionally reactive principles as Rx, but specially adapted for use with the Reactor according to the principle of "total reactivity" and connected in the form of a hook.
import useMrr from 'mrr/hooks'; const App = props => { const [state, set] = useMrr(props, { result: [name => fetch('//example.api/' + name).then(data => data.result), '-name', 'submit'], }); return <div> <input value={state.name} onChange={set('name')}/> <input type="submit" value="Check" onClick={set('submit')}/> { state.result && <div>Result: { state.result }</div> } </div>; }
The useMrr interface is similar to useState or useReducer: returns a state object (the values ​​of all streams) and a setter for putting values ​​into streams. But inside everything is a little different: each state field (= stream), except for those in which we put values ​​directly from DOM events, is described by a function and a list of parent streams, changing which will cause the child to recalculate. In this case, the values ​​of the parent streams will be substituted into the function. If we just want to get the value of the stream, but not react to its change, then we write a minus before the name, as in the case of name.
We got the right behavior, in essence, in one line. But it’s not just the brevity. Let's compare the obtained results in more detail, and first of all in such a parameter as readability and clarity of the resulting code.
In mrr, you will have almost complete separation of the "logic" from the "template": you will not have to write any complex imperative handlers in JSX. Everything is ultimately declarative: we just map the DOM event to the corresponding stream, practical without conversions (for input fields, the e.target.value value is automatically retrieved, unless you specify otherwise), and already in the useMrr structure we describe how child Thus, in the case of both synchronous and asynchronous data transformations, we can always easily see how our value is formed.
Comparing with : we didn’t even have to use additional operators: if as a result, the function mrr gets promise, it will automatically wait for its resolver and put the received data into the stream. Also, instead of the withLatestFrom operator, we used
passive listening (minus sign), which is more convenient. Imagine that in addition to the name, we will need to send other fields. Then in mrr we will add another passively listened stream:
result: [(name, surname) => fetch(...), '-name', '-surname', 'submit'],
And in Rx you will have to sculpt another one withLatestFrom with a map, or you can pre-merge name and surname into one stream.
But back to the hooks and mrr. A more readable dependency record, which always shows how data is generated, is perhaps one of the main advantages. The current useEffect interface fundamentally does not allow to respond to the flow of signals, which is why
you have to invent different quirks.
Another point is that the variant of ordinary hooks carries extra renders. If the user simply clicked on the button, this does not entail any further changes in the UI that the reactor needs to draw. However, the render will be triggered. In the version with mrr, the returned state will only be updated when the server has already received a response. Saving on matches, you say? Well, maybe. But for me personally, the principle of “in any incomprehensible situation, re-render”, underlying the basic hooks, causes rejection.
Superfluous renders also mean a new formation of event handlers. By the way, even ordinary hooks are bad here. Not only are handlers imperative, they also have to be regenerated at every render. And it will not be possible to fully use caching here, since Many handlers must be closed on the internal variables of the component. The mrr handlers are more declarative, and caching is already built in to mrr: set ('name') will be generated only once, and will be substituted from the cache on subsequent renders.
With an increase in the code base, imperative handlers can become even more cumbersome. Suppose that we also need to show the number of form submissions made by the user.
const App = () => { const [request, makeRequest] = useState(); const [name, setName] = useState(''); const [result, setResult] = useState(false); const [clicks, setClicks] = useState(0); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => { makeRequest(!request); setClicks(clicks + 1); }}/><br /> Clicked: { clicks } </div>; }
Not very nice looking. You can of course render the handler a separate function inside the component. The readability will increase, but the problem of regenerating the function with each render will remain, as will the problem of imperativeness. In essence, this is a common procedural code, despite the widespread belief that the React API is gradually changing towards a functional approach.
To those who find the scale of the problem exaggerated, I can answer that, for example, the developers of React themselves are aware of the problem of unnecessary generation of handlers, immediately offering us a crutch in the form of useCallback.
On mrr:
const App = props => { const [state, set] = useMrr(props, { $init: { clicks: 0, }, isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'], clicks: [a => a + 1, '-clicks', 'makeRequest'], }); return <div> <input onChange={set('name')}/> <input type="submit" value="Check" onClick={set('makeRequest')}/> </div>; }
A more convenient alternative is useReducer, allowing you to abandon the imperativeness of handlers. But other important problems remain: the lack of work with signals (since side effects will be responsible for the same useEffect), as well as worse readability for asynchronous transformations (in other words, it is more difficult to trace the relationship between the fields of the story, because ). If in the mrr dependency graph between the state fields (streams) is immediately clearly visible, in the hooks you will have to run a little up and down with your eyes.
Also, sharing in the same component useState and useReducer is not very convenient (again, there will be complex imperative handlers that will change something in useState
and dispute action), which is why most likely before developing a component you will have to accept this or that option.
Of course, consideration of all aspects can still go on and on. In order not to go beyond the scope of the article, I will touch on some less important points.
Centralized logging debug. Since in mrr all streams are contained in one hub, for debug it is enough to add one flag:
const App = props => { const [state, set] = useMrr(props, { $log: true, $init: { clicks: 0, }, isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'], clicks: [a => a + 1, '-clicks', 'makeRequest'], }); ...
After that, all changes to the threads will be displayed in the console. To access the entire state (that is, the current values ​​of all threads) there is a $ state pseudo-thread:
a: [({ name, click, result }) => { ... }, '$state', 'click'],
Thus, if needed or if you are very used to the redox style, you can write in the redac style on mrr, returning the new field value based on the event and the whole previous state. But the opposite (writing on the useReducer or editorial in the style of mrr) will not work, due to the lack of reactivity in these.
Work with time. Remember the two aspects of flows: value and response time, harmony and rhythm? So, working with the first in ordinary hooks is quite simple and convenient, but with the second one - no. By working with time, I mean the formation of child flows, the "rhythm" of which differs from the parent. First of all, these are various kinds of filters, debuffs, trotles, etc. You will most likely have to implement all this yourself. In mrr, you can use ready-made operators out of the box. The gentlemanly set mrr is inferior to the variety of operators Rx, but it has more intuitive naming.
Intercomponent interaction. I remember in the Redax it was considered good practice to create only one story. If we use useReducer in many components,
possible problem with the organization of interaction stor. On mrr, streams can freely “flow” from one component to another both up and down the hierarchy, but this will not create problems due to the declarative approach. In details
this topic, as well as other features of the mrr API, are described in the article Actors + FRP in React
The new Reactor hooks are beautiful and simplify our lives, but they have some drawbacks that a higher-level general purpose hook (state management) can eliminate. As such, useMrr from the mrr functionally reactive library was proposed and reviewed.
Problems and solutions:
On many points one can argue that they can be solved with custom hooks. But after all, this is exactly what is proposed, but instead of separate implementations for each individual task, a holistic, consistent solution is proposed.
Many problems have become too familiar to us to be clearly understood. For example, asynchronous conversions have always looked more complicated and confusing than synchronous ones, and hooks in this sense are no worse than earlier approaches (redaks, etc.). To understand this as a problem, you must first see other approaches that offer a more perfect solution.
This article is intended not to impose any specific views, but rather to draw attention to the problem. I am sure that other solutions exist or are being created that can be a worthy alternative, but have not yet become widely known. The future React Cache API can also make a significant difference. I will be glad to criticism and discussion in the comments.
Those interested can also watch a performance on this topic on March 28, kyivjs .
Source: https://habr.com/ru/post/445214/
All Articles