loading...

How to build bulletproof react components

jsco profile image Jesco Wuester ・6 min read

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,

view = f(application_state)

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

visual explanation

(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.

Illustration of state in ui

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

pagination

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:

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 by:

jsco profile

Jesco Wuester

@jsco

Hi 👋 I'm Jesco, I'm a frontend developer currently based in Amsterdam. On this blog, I'll talk about anything I find interesting. If you have any feedback please tweet at me @jescowuester

Discussion

markdown guide
 

Hi Jesco, thank you for your article. It's nice to put principles like this into words.
I'm wondering if there's not a typo in one of the exemples, shouldn't :

useEffect(()=>{
  updateDatabase(newPage)
})

be

useEffect(()=>{
  updateDatabase(newPage)
}, [newPage])
 

you should use functional update when the new value is referencing the old one
That's react basic

setIsToggled((prevToggle)=>!prevToggle)
 

Not really. As a newcomer reading the docs, you find an example where setCount(count + 1) is used instead of a callback: reactjs.org/docs/hooks-state.html#.... Even though the API Reference indeed recommends using a callback, it's not very clear why.

 

It's mostly for when the value updates extremely fast. That way even when 2 rerenders get batched together the value stays correct. I figured it wouldn't really matter for my example tho

That's what I meant to say
When I was a junior react developer, I always told to use callback whenever the new value is referencing the old one.
And indeed, doing simple setState it doesn't matter

But as the app grows, there are 2 cases that may cause the setState incorrect

  1. state update really fast
  2. multiple setState is called in different places

But I still tend to use callback whenever possible, because it's hard to justify when is okay to use the value straight away when the code is complex
Also when you are doing a large refactor, you can't rethink to make sure every single setState is safe

After the release of React hooks, I see more ppl write code that has a lot of side effect, useEffect everywhere, thus I think its more important to enforce this rule, otherwise ppl may fix a lot of silly bugs

 

Completely agree. In fact, in a function component the callback setup would also not help and the callbacks should be defined using a useCallback.

 
 

This was a fantastic article.

 
 

thank you! my react code renders often. guess there's much room for improvement!

 

Thanks, Jesco for the amazing article. After reading this article I realize that in our company application we've so many duplicate states. I'll try to minimize the use of states.

 
 

Great article 🌞. I was with one of svelte maintainers a while back. Great framework. Lots of potential.