99% of what you read and hear about react is dogmatic, religious almost fanatical garbage. React is a (html) rendering system, but more importantly it is an idea that with the powerful browser we have, we can simplify our dev lives.
How can we simplify?
It turns web dev into game dev. We could run the render 60frames/sec, but as an optimization we only render when stuff changes.
React makes the 1 part easy by providing a few abstractions to hide the data.
And it makes the 2 part work by some clever optimizations.
The 3 part is just common sense of divide and conquer, but let's start with it.
If you are familiar with React skip this part. Also there are better and more in-depth explanations of how this work.
The core concept of react is a component.
Whats a component? It is a function, that gets some inputs from its parent called props and produces a tree of elements as output. So far so good? and what is an element you ask? Element is a simple object that has props, children elements (maybe empty) and a constructor: either a html element name (div
) or a component - a function.
To get the ball rolling we have to give react one root element: the constructor, props and children.
Conceptually rendering in react means walking the tree of elements and calling the constructor method (aka rendering the component aka render) to get the next level of elements or creating the html element if the constructor is just a html element name.
In real life, there is more going on, since we want to avoid rendering when not needed, we might want to associate some runtime data with each element that is preserved between renders and we want to react to events.
The way react avoids rendering is to only render if the props passed to a component changed. How does react know props changed? It keeps the element tree from the previous render around and checks whether the previous props properties are the same as the new ones. This is a fast check eg === , and it is shallow. That means if you pass an object in props, you have to clone it so that it will register as changed {…x}. And because it is shallow changes to x’s properties will not be detected!
How does react compare the previous and new element tree? It walks the new tree starting at the root. It compares the old root and if true we continue. Now it looks at the first child of the new root, and compares old root’s children to it one at the time until it finds one where compare is true. What is this magic compare? -> checks the constructor is the same and checks the key prop is the same. (This is super handy for lists but can be useful in other scenarios). If compare is true, react keeps the component instance (state), otherwise a new component instance is created (this is called mounting). If the element is new or props changed, the constructor is called and we continue with the children elements. Then we go to the next root child element and so forth. All elements in the old tree that have not compared true, (and were not skipped because they or the parents did not change) will be discarded (unmounted).
Once you understand the react rendering, you can game the system and for example provide explicit rendering hints to react, without mucking with immutability. But, you really should not, unless your profiler tells you too!
There is great freedom in the stateless nature of dumb data being turned into UI in a pure and predictable way. You don’t care about what happened before, how the data got this way or why. Your job is to render it.
Dumb data makes me look smart. Smart data makes me hunt bugs for days and makes my head hurt trying to visualize all the interactions and how they can go wrong.
Be productive, say no to smart data, active records and all that garbage.
Imaging you are building a game. The user's player shoots the gun. Transform your global world data that contains a bullet object (a simple map of properties) and just render.
But I need/want to have state in my child component… no you don't. There is only one reason to have state in a component that is not the root. And then there is one more reason why state is used, but its not really state.
For example if your component is a combobox and has a popup element. You will need an open/closed state to keep track of the popup. And it is not necessary to keep this information in the global state. Since the open/close state is transient it does not need to be preserved. This is a rule of thumb, it might be that for your use-case the open/close state has to be part of the world data or available to the parent component.
In a game, we draw all the pixels, it's therefore hard to come up with a good analogy. Let's say we have a chat feature in our game. We paint a translucent stream of messages onto the game screen. From the point of view of our game's world data the chat windows doesn’t matter, the rendered game output is the same. Only at the very end when the game rendering is done the chat messages are drawn on top of the final rendered game bitmap. Or not - depending on the chat-window-open state.
Let’s say we have a component that displays additional business data related to whatever props we get. For example on a customer page we have tabs that show customer details, list of visits, list of quotes and invoices. One options is to load all the related data (visits and quotes). This might work, but if the user does not look at the tab of visits or quotes, we just wasted resources loading data that is not needed and even more importantly we made the user wait while we load data she does not care about. So we only load the data when the react component is actually rendered and we cache the data so we don’t load it each time we render. If the data loading is synchronous we can just use the useMemo hook, but this is unlikely and we’ll need an asynchronous fetch to the server. The pattern is useEffect to start the loading and a useState to keep the data. We have omitted loading state and error handling for clarity. There is a subtle bug here. Can you spot it? We’ll talk about it later.
useState
is considered harmfulComponent “instance data” (eg whatever we have in useState or useMemo or useRef) lifetime is not tied to props. When rendering react will compare the tree of elements rendered last time, with the elements rendered now. If the elements are the same they will get reused (and the component instance data lives on), otherwise the old component if any is un-mounted and the new component is mounted (with fresh empty instance data).
Usually this is great and it is the reason React is fast. Now consider this situation: we have an async cache loaded (2) with visits for customer Alice. But the user now selects the customer Bob. Our useEffect call will be called again, because we watch the customerId prop. But until the load is complete we will render alice’s visits! Now there is a reason why cache invalidation is considered a hard problem - there are many subtle situation we need to handle. So what can we do?
import { useCallback, useRef, useState } from "react";
export const CustomerVisits = (props: {customerId: string}) => {
//const [visits, setVisits] = useState([]);
const [visits, setVisits] = useDerivedState([], [props.customerId]); // fix 3.
useEffect(()=>{
setVisits([]) // fix 2.
const asyncLoad = async () => {
const resp = await fetch("/visits/"+props.customerId);
const newVisits = await resp.json();
setVisits(newVisits);
}
asyncLoad();
},[props.customerId]);
return <div>
{visits.map(visit=>{
return <div>{visit.name} - {visit.date}</div>
})}
</div>
}
export const useRender = () => {
const [_, r] = useState(0);
const render = useCallback(() => {
r(x => ((x + 1) & 0xffff));
}, [r]); // do not just flip true/false. Double render in strict debug mode could flip the value back!
return render;
}
export const useDerivedState = <T extends unknown>(initial: (T | (()=> T)), monitor: any[]): [T, (v: T | ((prev: T) => T)) => void] => {
const r = useRef(monitor);
const valueRef = useRef<T>(undefined as T);
const forceRender = useRender();
if (valueRef.current === undefined || !r.current.every((x, i) => Object.is(x, monitor[i]))) {
if (typeof initial === "function")
valueRef.current = (initial as (() => T))();
else
valueRef.current = initial as T;
r.current = monitor;
}
const setValue = useCallback((value: T | ((prev: T) => T)) => {
let newValue: any;
if (typeof value === "function")
newValue = (value as ((prev: T) => T))(valueRef.current);
else
newValue = value as T;
if (!Object.is(valueRef.current, newValue)) {
valueRef.current = newValue;
forceRender();
}
}, [valueRef]);
return [valueRef.current, setValue];
}
Consider the situation where we start loading alice’s visits, but the user changes to bob before we are done. Now we will clear the visits in useEffect but what if bob’s visits come sooner than alice’s? (Eg there is a race between alice and bob’s visits - so race condition). Well there is nothing that prevents this situation, therefore if the internet speeds align just right - we can show alice’s visits instead of bob’s!
Naive code
export const CustomerVisits = (props: {customerId: string}) => {
const [visits, setVisits] = useState([]);
useEffect(()=>{
const asyncLoad = async () => {
const resp = await fetch("/visits/"+props.customerId);
const newVisits = await resp.json();
setVisits(newVisits);
}
asyncLoad();
},[props.customerId]);
return <div>
{visits.map(visit=>{
return <div>{visit.name} - {visit.date}</div>
})}
</div>
}
Now that will not do, so how can we fix it? Remember there is a optional cleanup for useEffect - and we shall set a cancel flag there. While that is not strictly needed, we can also cancel a fetch in progress - handy if we are fetching some large pictures with visits…
Better code
{
useEffect(()=>{
const controller = new AbortController();
let cancel = false;
// fix 1. clear cache
setVisits([]);
const asyncLoad = async () => {
// pass
try {
const resp = await fetch("/visits/" + props.customerId, { signal: controller.signal });
const newVisits = await resp.json();
if (!cancel)
setVisits(newVisits);
}
catch (e) {
// aborted or other error
}
}
asyncLoad();
// cleanup
return () => {
cancel = true; // fix 2. we don't care about the data, prevent the race condition
controller.abort(); // fix 3. actually abort network download
}
},[props.customerId]); // fix 4. make sure we have the customerId here so that we reload the visits when it changes!
}
As with all frameworks and libraries, React is a tool. Whether it is useful, and how it should be used depends on your situation.
The above is just our limited experience, it might very well not fit your needs.
However, we feel that simple is better, and React can dramatically simplify your life, if you go with the React idea - pure rendering or pure data.
That's all, don't give in to the dogma, and happy hacking!