Author – RichLab Ink Book
By the end of 2021, React Hooks were dominating the React ecosystem, sweeping almost every React app. It fits so well with the Function Component and Fiber architecture that we have no reason to reject it at this point.
To be sure, Hooks solve the age-old problem of React Mixins, but I don’t think Hooks are a good abstraction at this point in time in terms of their odd usage experiences.
In this article, I will take a critical look at what I see as react-hooks
“Weird” rules
React officially has a number of Hooks writing specifications to avoid bugs, but this also exposes its problems.
named
Hooks are not ordinary functions; we use names beginning with use to distinguish them from other functions.
This, in turn, breaks the semantics of function naming. The fixed use prefix makes it difficult for Hooks to name. You are confused with names like useGetState and you are confused about useTitle.
By contrast, streams with private member variables beginning with _ and ending with $do not have similar problems. Of course, this is just a difference in usage, not a big deal.
Calling sequence
When using useState, have you ever wondered: useState holds State for me even though render() is called every time, how does it know what State I want if I write many?
const [name, setName] = useState('xiaoming')
console.log('some sentences')
const [age, setAge] = useState(18)
Copy the code
How does React know when I want a name and when I want an age?
In the example code above, why does useState return the string name on line 1 and the number age on line 3? After all, it looks like we just called useState twice “uneventfully.”
The answer is “timing”. The timing of useState calls determines the result, that is, the first useState “saves” the state of name and the second useState “saves” the state of age.
// State is declared and updated by literals in Class Component
this.setState({
name: 'xiaoming'.// State literal 'name', 'age'
age: 18,})Copy the code
React dictates this with a simple “timing” (the data structure behind it is a linked list), which leads to Hooks’ strict call timing requirements. Avoid all branching, don’t let Hooks “on and off”.
❌ typical error
if (some) {
const [name, setName] = useState('xiaoming')}Copy the code
This requirement is entirely dependent on developer experience or Lint, and from the point of view of the average third-party Lib, API design that requires timing calls is extremely rare and counter-intuitive.
The ideal API package should be one with the least cognitive burden on the developer. Just like encapsulating a pure function add(), it doesn’t matter what environment the developer is calling it from, how deep it is, or what time sequence it uses, as long as the parameters passed in meet the requirements, it will work, simple and pure.
function add(a: number, b: number) {
return a + b
}
function outer() {
const m = 123;
setTimeout(() = > {
request('xx').then((n) = > {
const result = add(m, n) // Intuitive calls: no environment requirements})},1e3)}Copy the code
It’s safe to say “React doesn’t make Hooks not require environments”, but it’s also true that this approach is weird.
A similar situation can be seen in Redux-Saga, where it is easy for developers to write “intuitive” code like the one below and never “see” that something is wrong.
import { call } from 'redux-saga/effects'
function* fetch() {
setTimeout(function* () {
const user = yield call(fetchUser)
console.log('hi', user) // Will not be executed up to this point
}, 1e3)}Copy the code
The yield call() is invoked in the Generator and seems really “reasonable”. In practice, however, function* requires a Generator execution environment, and Call also requires a Redux-saga execution environment. With this double requirement, the example code will not work.
UseRef’s “Against all odds”
In essence, useRef is the equivalent of react.createref () in the Class Component era.
The initial sample code in the official documentation supports this (see below, with cuts) :
function TextInputWithFocusButton() {
const inputEl = useRef(null);
return (
<input ref={inputEl} type="text" />
);
}
Copy the code
But because of its special implementation, it is often used for other purposes.
In the React Hooks source code, useRef only initializes objects at Mount time, while Update time returns memoizedState. This means that the references retained by useRef never change during a full lifetime.
This feature makes it the save of Hooks closures.
“No decision, useRef!” (There are many abuses of useRef that are not covered in this article.)
Each execution of a Function has its corresponding Scope, and for object-oriented purposes, this reference is the Context that connects all scopes (under the same Class, of course).
class Runner {
runCount = 0
run() {
console.log('run')
this.runCount += 1
}
xrun() {
this.run()
this.run()
this.run()
}
output() {
this.xrun()
// Even if the 'run' is called indirectly, the 'run' execution information can still be obtained
console.log(this.runCount) / / 3}}Copy the code
In React Hooks, each Render is determined by the State at which the Context is set, and the Context is refreshed when the Render completes it. Elegant UI rendering, clean and neat.
UseRef, however, somewhat defies the designer’s original intention. UseRef can span the Scope generated by multiple Render sequences. It can preserve the rendered logic that has been executed, but it can also keep the rendered Context unreleased.
And if this reference is the main side effect of object orientation, so is useRef. At this point, Function Components with useRef writing are doomed to be “functional”.
Be careful with
Defective life cycle
When constructing
There is also a big “Bug” between the Class Component and the Function Component. The Class Component only instantiates once and then only executes render(), whereas the Function Component keeps executing itself.
This results in the absence of the corresponding constructor for the Function Component compared to the Class Component. You can also simulate constructor if you have a way to execute a piece of logic in Function only once.
// For example, use useRef to construct
function useConstructor(callback) {
const init = useRef(true)
if (init.current) {
callback()
init.current = false}}Copy the code
The constructor should not be analogous to useEffect in terms of life cycle, as the two may differ greatly if the actual node takes longer to render.
That is, the Class Component and Function Component lifecycle apis are not exactly one-to-one, which is a big error.
UseEffect with chaotic design
After understanding the basic usage of useEffect, plus taking its literal meaning (listen for side effects), you might mistake it for Watcher.
useEffect(() = > {
// watch to 'a' change
doSomething4A()
}, [a])
Copy the code
But soon you realize something is wrong, if the variable A fails to trigger re-render, the listener will not work. That is, it should actually be used to listen for State changes, useStateEffect. The deps parameter, however, does not restrict the input to State. It’s hard not to think it’s a design flaw if it’s not for some particular action.
const [a] = useState(0)
const [b] = useState(0)
useEffect(() = > {
// Assume this is a listener for 'a'
}, [a])
useEffect(() = > {
// assume this is the listener of 'b'
// In fact, 'a' is not monitored even if 'b' does not change, but it is still executed because 'A' changes
}, [b, Date.now()]) // Because date.now () is a new value every time
Copy the code
UseStateEffect is also poorly understood because useEffect is actually responsible for listening on Mount, and you need “empty dependencies” to distinguish Mount from Update.
useEffect(onMount, [])
Copy the code
The more capabilities a single API supports, the more chaotic its design. Complex features not only test the developer’s memory, they are also difficult to understand, and more likely to break down due to misinterpretation.
useCallback
Performance issues?
In Class Component we often bind functions to this and keep a unique reference to it to reduce unnecessary rerendering of child components.
class App {
constructor() {
/ / method
this.onClick = this.onClick.bind(this)}onClick() {
console.log('I am `onClick`')}/ / method 2
onChange = () = > {}
render() {
return (
<Sub onClick={this.onClick} onChange={this.onChange} />)}}Copy the code
The corresponding scheme in Function Component is useCallback:
// ✅ effective optimization
function App() {
const onClick = useCallback(() = > {
console.log('I am `onClick`')}, [])return (<Sub onClick={onClick} />)}// ❌ error demonstration, 'onClick' is new in each Render, will be rerendered accordingly
function App() {
// ... some states
const onClick = () = > {
console.log('I am `onClick`')}return (<Sub onClick={onClick} />)}Copy the code
UseCallback can keep the reference to the function through multiple rerenders, and the onClick on line 2 is always the same, thus avoiding rerendering of the child .
UseCallback source code is actually very simple:
Mount saves only callback and its dependent arrays
The Update period determines that the last callback is returned if the dependent array is consistent
The useMemo implementation, by the way, is only different from useCallback with one more Invoke step:
An infinite set of Eva✓
In contrast to the performance problems associated with not using useCallback, the real problem is the reference dependencies associated with useCallback.
// When you decide to introduce 'useCallback' to solve the duplicate rendering problem
function App() {
// Request the parameters required by A
const [a1, setA1] = useState(' ')
const [a2, setA2] = useState(' ')
// The parameters required for request B
const [b1, setB1] = useState(' ')
const [b2, setB2] = useState(' ')
// Request A, and process the return result
const reqA = useCallback(() = > {
requestA(a1, a2)
}, [a1, a2])
// request A and B, and process the return result
const reqB = useCallback(() = > {
reqA() // the reference to 'reqA' is always the first one,
requestB(b1, b2) // reqA in reqB is obsolete when 'a1' and 'a2' change.
}, [b1, b2]) // Add 'reqA' to 'reqB' dependency array.
// But when you call 'reqA',
// How do you know "should" to be added to the dependency array?
return (
<>
<Comp onClick={reqA}></Comp>
<Comp onClick={reqB}></Comp>
</>)}Copy the code
As you can see from the example above, when usecallbacks previously had dependencies, their reference maintenance becomes complicated. Be careful when calling a function. You need to consider whether it has references that are out of date.
Use-Universal
The heyday of Hooks has seen the birth of many toolbanks. Ahooks alone has 62 custom Hooks. “Everything can use”. Or do we really need so many Hooks?
Proper encapsulation?
Although in the React documentation, it is also officially recommended that you wrap custom Hooks to improve logic reuse. But I think it depends, not all life cycles need to be enclosed in Hooks.
// 1
function App() {
useEffect(() = > { // useEffect cannot be async function
(async() = > {await Promise.all([fetchA(), fetchB()])
await postC()
})()
}, [])
return (<div>123</div>)}// --------------------------------------------------
// 2. Custom Hooks
function App() {
useABC()
return (<div>123</div>)}function useABC() {
useEffect(() = >{(async() = > {await Promise.all([fetchA(), fetchB()])
await postC()
})()
}, [])
}
// --------------------------------------------------
// 3. Traditional encapsulation
function App() {
useEffect(() = > {
requestABC()
}, [])
return (<div>123</div>)}async function requestABC() {
await Promise.all([fetchA(), fetchB()])
await postC()
}
Copy the code
In the above code, wrapping the logic in the lifecycle as HookuseABC instead couples the lifecycle callbacks, reducing reusability. Even if we didn’t include any Hooks in our package, we would just wrap a useEffect on the call without any trouble, and make the logic usable in places other than Hooks.
If useEffect and useState are used no more than twice in custom Hooks, it is worth considering the need for this Hook to be unwrapped.
Simply put, hooks are either “lifecycle dependent” or “State dependent”, otherwise they are not necessary.
Repeated calls to
The “counterintuitive” aspect of a Hook call is that it is called repeatedly with re-rendering, which requires Hook developers to have some expectation of this repeated call.
As in the previous example, it is easy to rely on useEffect to encapsulate a request because it depends on the lifecycle to ensure that the request will not be repeated.
function useFetchUser(userInfo) {
const [user, setUser] = useState(null)
useEffect(() = > {
fetch(userInfo).then(setUser)
}, [])
return user
}
Copy the code
But is the useEffect really appropriate? If it’s DidMount, it’s late, because DidMount is late if the render is too complex and hierarchical.
For example, ul renders 2000 Li:
function App() {
const start = Date.now()
useEffect(() = > {
console.log('elapsed:'.Date.now() - start, 'ms')}, [])return (
<ul>
{Array.from({ length: 2e3 }).map((_, i) => (<li key={i}>{i}</li>))}
</ul>)}// output
// elapsed: 242 ms
Copy the code
What about using state drives instead of life cycles? It seems like a good idea to retrieve the data if the state changes, and it seems reasonable.
useEffect(() = > {
fetch(userInfo).then(setUser)
}, [userInfo]) // When request parameters change, retrieve data again
Copy the code
But the timing of the initial implementation was still not ideal, again in DidMount.
let start = 0
let f = false
function App() {
const [id, setId] = useState('123')
const renderStart = Date.now()
useEffect(() = > {
const now = Date.now()
console.log('elapsed from start:', now - start, 'ms')
console.log('elapsed from render:', now - renderStart, 'ms')
}, [id]) // Listen for 'id' changes here
if(! f) { f =true
start = Date.now()
setTimeout(() = > {
setId('456')},10)}return null
}
// output
// elapsed from start: 57 ms
// elapsed from render: 57 ms
// elapsed from start: 67 ms
// elapsed from render: 1 ms
Copy the code
This is why the useEffect design is confusing above, because when you think of it as a State Watcher, it also implies “first time in DidMount” logic. In the literal sense, this logic is a side Effect…
State-driven encapsulation has other problems besides call timing:
function App() {
const user = useFetchUser({ // At first glance, there seems to be no problem
name: 'zhang'.age: 20,})return (<div>{user? .name}</div>)}Copy the code
In fact, component rerendering causes request input recalculation -> the object declared by the literal is new every time -> useFetchUser therefore keeps asking -> requests change the State user in the Hook -> outer component
rerender.
It’s a constant cycle!
Of course, you can use Immutable to solve the problem of repeating requests for the same argument.
useEffect(() = > {
// xxxx
}, [ Immutable.Map(userInfo) ])
Copy the code
But in general, encapsulation Hooks are much more than just changing the organization of your code. Making data requests, for example, can lead you down the state-driven path, and you’ll also have to deal with the new complications that state drives bring.
For the sake of a Mixin?
The capabilities of mixins are not exclusively the preserve of Hooks, and we can use decorators to encapsulate a Mixin mechanism. That is, Hooks cannot rely on Mixin’s ability to fight back.
const HelloMixin = {
componentDidMount() {
console.log('Hello,')}}function mixin(Mixin) {
return function (constructor) {
return class extends constructor {
componentDidMount() {
Mixin.componentDidMount()
super.componentDidMount()
}
}
}
}
@mixin(HelloMixin)
class Test extends React.PureComponent {
componentDidMount() {
console.log('I am Test')}render() {
return null
}
}
render(<Test />) // output: Hello, \n I am Test
Copy the code
Hooks, however, are more assembly capable and easily nested. Beware of deep Hooks, there may be a dangerous useEffect lurking around the corner that you don’t know about.
summary
- This article does not advocate that the Class Component rejects the use of React Hooks. Instead, it wants to understand Hooks in more detail by comparing the two.
- The quirks of React Hooks are also potential sticking points.
- Before Hooks, Function Components were Stateless, small, reliable, but limited in functionality. Hooks give Function Component the State capability and provide the lifecycle, making it possible to use Function Component on a large scale.
- Hooks’ “elegance” comes from a nod to the functional, but
useRef
That makes Hooks far from elegant. - There are still problems with implementing React Hooks on a large scale, both in terms of semantic understanding and the need for encapsulation.
- Innovation is not easy, expect better design after React official.