DEV Community

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}`;
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
Enter fullscreen mode Exit fullscreen mode

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>
  );
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
  );
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (12)

Collapse
marklai1998 profile image
Mark Lai

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

setIsToggled((prevToggle)=>!prevToggle)
Collapse
eliseumds profile image
Eliseu Monar dos Santos • Edited

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.

Collapse
jsco profile image
Jesco Wuester Author

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

Thread Thread
marklai1998 profile image
Mark Lai • Edited

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

Collapse
arhsim profile image
arhsim

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

Collapse
uncleroastie profile image
Ron Male

Excellent article

Collapse
rhuanga10 profile image
riiniii

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

Collapse
sagar profile image
Sagar

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.

Collapse
mhomol profile image
Mike Homol

This was a fantastic article.

Collapse
mkal1375 profile image
Mahdi Kalhor

👏🏻👏🏻👍🏻

Collapse
anthonybrown profile image
Tony Brown

💪👍👏🤙

Collapse
bernardbaker profile image
Bernard Baker

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

Forem Open with the Forem app