How to build bulletproof react components
Jesco Wuester
Posted on April 12, 2020
Introduction
React is a declarative framework. This means instead of describing what you need to change to get to the next state (which would be imperative), you just describe what the dom looks like for each possible state and let react figure out how to transition between the states.
Shifting from an imperative to a declarative mindset is quite hard and often times when I spot bugs or inefficiencies in code it's because the user is still stuck in an imperative mindset.
In this blog post I' ll try to dive deep into the declarative mindset and how you can use it to build unbreakable components.
Imperative vs Declarative:
check out this example:
Every time you click the button the value toggles between true
and false
. If we were to write this in an imperative way it would look like this:
toggle.addEventListener("click", () => {
toggleState = !toggleState;
// I have to manually update the dom
toggle.innerText = `toggle is ${toggleState}`;
});
Full example here
And here is the same thing written in declarative code:
const [toggle, setToggle] = useState(false);
// notice how I never explicitely have to update anything in the dom
return (
<button onClick={() => setToggle(!toggle)}>
toggle is {toggle.toString()}
</button>
);
full example here
Every time you want to change the isToggled
value in the first example you have to remember to update the dom as well, which quickly leads to bugs. In React, your code "just works".
The Mindset
The core of your new mindset should be this quote:
Your view should be expressed as a pure function of your application state.
or,
view = f(application_state)
or,
your data goes through a function and your view comes out the other end
React's function components align much closer to this mental model than their old class components.
This is a bit abstract so let's apply it to our toggle component from above:
the "toggle is" button should be expressed as a pure function of the
isToggled
variable.
or
button = f(isToggled)
or
(I'll stick to the mathematical notation from now on but they're basically interchangeable)
Let's extend this example. Say whenever isToggled
is true
I want the button to be green, otherwise, it should be red.
A common beginner mistake would be to write something like this:
const [isToggled, setIsToggled] = useState(false);
const [color, setColor] = useState('green');
function handleClick(){
setIsToggled(!toggle)
setColor(toggle ? 'green' : 'red')
}
return (
<button style={{color}} onClick={handleClick}>
toggle is {isToggled.toString()}
</button>
);
If we write this in our mathematical notation we get
button = f(isToggled, color)
right now our application_state
is made out of isToggled
and color
, but if we look closely we can see that color
can be expressed as a function of isToggled
color = f(isToggled)
or as actual code
const color = isToggled ? 'green' : 'red'
This type of variable is often referred to as derived state
(since color
was "derived" from isToggled
)
In the end this means our component still looks like this:
button = f(isToggled)
How to take advantage of this in the real world
In the example above it was quite easy to spot the duplicate state, even without writing it out in our mathematical notation, but as our apps grow more and more complex, it gets harder to keep track of all your application state and duplicates start popping up.
A common symptom of this is a lot of rerenders and stale values.
Whenever you see a complex piece of logic, take a few seconds to think about all the possible pieces of state you have.
dropdown = f(selectedValue, arrowDirection, isOpen, options, placeholder)
then you can quickly sort out unnecessary state
arrowDirection = f(isOpen) -> arrowDirection can be derived
You can also sort what state will be in the component and what will come in as props. isOpen
for example usually doesn't need to be accessed from the outside of a dropdown.
From that we can tell that our component's api is probably going to look like this: <dropdown options={[item1, item2]} selectedValue={null} placeholder='Favorite food' />
.
Writing the component now will be incredibly easy since you already know exactly how it's going to be structured. All you need to do now figure out is how to render your state to the dom.
One more example
This looks like a lot of state at first glance, but if we look closely we can see that most of them can be derived:
isDisabled = f(selectedValue, range)
"..." position = f(selectedValue, range)
middle fields = f(selectedValue, range)
amount of fields = f(selectedValue, range)
So what remains, in the end, is just
pagination = f(selectedValue, range)
here's my implementation:
It's robust, fast and relatively easy to read.
Let's take it one step further and change the route to /${pageNumber}
whenever the pagination updates.
Your answer may look somewhat like this:
const history = useHistory();
const [page, setPage] = useState(1);
function handleChange(newPage){
setPage(newPage)
history.push(`/${newPage}`);
}
useEffect(()=>{
setPage(history.location.pathname.replace("/", ""))
},[])
return (
<div className="App">
<Pagination value={page} range={12} onChange={handleChange} />
</div>
);
If it does, then I have some bad news: you have duplicate state.
pageNumber = f(window.href)
pageNumber doesn't need its own state, instead, the state is stored in the url. here is an implementation of that.
Other implications
Another big implication of our new mindset is that you should stop thinking in lifecycles.
Since your component is just a function that takes in some state and returns a view it doesn't matter when, where and how your component is called, mounted or updated. Given the same input, it should always return the same output. This is what it means for a component to be pure.
That's one of the reasons why hooks only have useEffect
instead of componentDidMount
/ componentDidUpdate
.
Your side effects should also always follow this data flow. Say you want to update your database every time your user changes the page, you could do something like this:
function handleChange(newPage) {
history.push(`/${newPage}`);
updateDatabase(newPage)
}
but really you don't want to update your database whenever the user clicks, you want to update your database whenever the value changes.
useEffect(()=>{
updateDatabase(newPage)
})
Just like your view, your side effects should also be a function of your state.
Going even deeper
There are a couple of exceptions to this rule in react right now, a significant one is data fetching. Think about how we usually fetch data:
const [data, setData] = useState(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(()=>{
setIsLoading(true)
fetch(something)
.then(res => res.json())
.then(res => {
setData(res)
setIsLoading(false)
})
},[])
return <div>{data ? <DataComponent data={data} /> : 'loading...'}</div>
There is a ton of duplicate state here, both isLoading
and data
just depend on whether our fetch promise has been resolved.
We need to do it this way right now because React can't resolve promises yet.
Svelte solves it like this:
{#await promise}
<!-- promise is pending -->
<p>waiting for the promise to resolve...</p>
{:then value}
<!-- promise was fulfilled -->
<p>The value is {value}</p>
{:catch error}
<!-- promise was rejected -->
<p>Something went wrong: {error.message}</p>
{/await}
React is working on something similar with suspense for data fetching
Another big point is animation. Right now, updating state at 60fps is often not possible. A great library that solves that in a declarative way is react spring. Svelte again has a native solution for this and I wouldn't be surprised if that's something else react will look at in the future.
Final thoughts
whenever
- your app rerenders often for no real reason
- you have to manually keep things in sync
- you have issues with stale values
- you don't know how to structure complex logic
take a step back, look at your code and repeat in your head:
Your view should be expressed as a pure function of your application state.
Thanks for reading โค
If you didn't have that "aha-moment" yet I recommend building out the pagination or any component that you can think of and follow exactly the steps outlined above.
If you want to dive deeper into the topic I recommend these 2 posts:
- https://medium.com/@mweststrate/pure-rendering-in-the-light-of-time-and-state-4b537d8d40b1
- https://rauchg.com/2015/pure-ui/
If you think there is something I could make clearer or have any questions/remarks feel free to tweet at me or just leave a comment here.
Posted on April 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 18, 2024