State Behaves Like a Snapshot! 📸

State Behaves Like a Snapshot! 📸

Ever tried to access a state variable in React just after updating it and wondered why you still get the previous value? Yes? Well, by the end of this article, you should understand why this behavior occurs in React and how to work around it.

Typically, when you want to update a state variable, you would utilize the setter function made available at initialization.

const [name, setName] = useState('');

In the expression above, setName is the setter function that lets us update the name variable, triggering a re-render and update of the UI. However, some times we would like to immediately access the state's newly updated value in order to perform some other action.

Let's jump right into a real-world example.

Let's say we have a component that simply collects a book title query, displays it and also provides a list of suggestions using the title as a query.

We should be able to see live updates as we change the value in the input box. Every time, we edit the value, the setter function, setTitle, runs and React triggers a re-render of the component and the UI updates to the new title value.

What if we want to immediately access this title to send to an API endpoint as a query in order to access a list of books that our user may be searching for i.e. suggestions? Here is how that may look like:

// searchBooks.jsx
export default function searchBooks() {
  const [title, setTitle] = useState("");

  function handleInputChange(e) {
    setTitle(e.target.value);
    try {
      const url = `https://www.mybooks.com/books/suggestions?q=${title}`;
      fetch(url)
        .then((response) => response.json())
        .then((data) => console.log(data));
    } catch (error) {
      console.log(error);
      // send to logging service
    }
  }

  return (
    // ...
  );
}

If all goes well, we expect our GET request to return an array of 3 book suggestions that are similar to our query, which we can then iterate over to display to our users. When they click on it, they would then be redirected to the appropriate page.

For now, we would replace the GET request to our non-existent API with a simple promise that simulates a response from the backend and resolves with a list of suggestions from our database after a second(1000ms).

// data.js

// simulates database
export const data = [
  {
    title: "The Birth of Tragedy",
    author: "Friedrich Nietzsche",
    url: "https://www.mybooks.com/books/the-birth-of-tragedy",
  },
  {
    title: "Genghis: Birth of an Empire",
    author: "Conn Iggulden",
    url: "https://www.mybooks.com/books/genghis-birth-of-an-empire",
  },
  {
    title: "Transcendent Kingdom",
    author: "Yaa Gyasi",
    url: "https://www.mybooks.com/books/transcendent-kingdom",
  },
  {
    title: "Beyond Good and Evil ",
    author: "Friedrich Nietzsche",
    url: "https://www.mybooks.com/books/beyond-good-and-evil",
  },
  {
    title: "Billy Summers",
    author: "Stephen King",
    url: "https://www.mybooks.com/books/billy-summers",
  },
];
import "./styles.css";
import { useState, Fragment } from "react";
import { data } from "./data";

export default function searchBooks() {
  const [title, setTitle] = useState("");
  const [suggestions, setSuggestions] = useState([]);

  const getSuggestions = () => {
    try {
      const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
          const filteredSuggestions = title
            ? data
                ?.filter((suggestion) =>
                  suggestion.title.toLowerCase().includes(title.toLowerCase())
                )
                ?.slice(0, 3)
            : [];
          resolve(filteredSuggestions);
        }, 1000);
      }).then((res) => setSuggestions(res));
      promise;
    } catch (error) {
      console.log(error);
      // send to logging service
    }
  };

  function handleInputChange(e) {
    setTitle(e.target.value);
    getSuggestions();
  }

  return (
    <div className="App">
      <input type="text" value={title} onChange={(e) => handleInputChange(e)} />
      <h3>Book title: {title}</h3>
      {suggestions?.map((suggestion) => (
        <Fragment key={suggestion.title}>
          <a href={suggestion.url}>{suggestion.title}</a>
          <br />
        </Fragment>
      ))}
    </div>
  );
}

If you don't know how useState works yet, you can learn more about it here.

The expected behavior of our component after a user enters a search query is to:

  • set the title state to the newly entered query in our handleInputChange event handler

  • filter the data/books array using the newly set title

  • return the filtered data as a list of suggestions

  • store this array in a 'suggestions' state variable that triggers an

  • update of the UI with a list of suggestions

When we run this code however, we see that there's no live update of the UI with suggested books as we change the search query. We know we are updating the title because the <h3> tag is being updated. However, we see only old updates where it seems like our 'database' is being filtered by old title data.

To debug, let's try logging our title variable before the promise.

function getSuggestions(e) {
  try {
    console.log(title)
    const promise = new Promise((resolve, reject) => {
      // ...
    });
    promise;
  } catch (error) {
    console.log(error);
    // send to loggin service
    }
  }

From the video above, we can see that we keep logging the previous title value instead of the new one. Therefore, it is the previous value that is being used to filter the array.

What causes this behavior?

When we update state using the setter function in a render, Render #1, we do not change the state directly. Instead, we trigger a re-render, Render #2, which would then have the newly updated value. We do not have access to the new value in the current render. Setting state only changes it for the next render.

For our example above, when we change the value in our input box, the following happens:

  1. the handleInputChange() event handler executes

  2. setTitle sets title to the value in our textbox and requests a re-render from React

  3. React re-renders the component with the new title value (Render #2)

Therefore, we can see that Render #1, our render where we set the title value, would not have access to the new value of title. However, Render #2, would.

This explains the "delayed" behavior we see when we log our title value. The render (Render #1) that logs the title value, still only has access to the current value and not the updated/new title value.

React takes snapshots of your render within the limits of time. This snapshot is the JSX returned from calling your component. When it calls your component, it calculates your props, functions, event handlers and variables using the state at the time of the render. React then updates the screen based on this snapshot of your component. This snapshot is interactive, in that you can interact with it and expect a response based on the logic already present in that snapshot e.g. event handlers.

This is how React re-renders your component. When you set state, React:

  1. calls your function again

  2. which returns a new JSX snapshot that has been computed based on the state change. In this step, the new value is provisioned and used to calculate the event handlers and other functions that use it.

  3. React then updates the screen to match the snapshot your function (component) returned. In this step, the <h3> tag now shows the new title.

React keeps the state values “fixed” within one render’s event handlers. Variables and event handlers don’t “survive” re-renders. Every render has its own event handlers and React waits until all code in the event handlers has run before processing your state updates.

This is why logging the title returns the value before the update. It's because that line of code ran using a snapshot of the state at the time the user interacted with it. The state simply doesn't change while the code is still running!

Let's say we change our title from 'tra' to 'trag', here's a representation of what happens:

  1. In Render #1, React computes our snapshot using 'tra' as the title state.

  2. Then, we change title from 'tra' to 'trag' in the input box and our event handler runs

  3. The event handler sets state to 'trag' and React queues a new render with 'trag' as the state

  4. The block of code in the event handler is not done yet. It runs getSuggestions(). Remember we are still in Render #1 which still believes title state is 'tra'.

  5. In getSuggestions() where we use the title value, we log 'tra' to the console and filter our books data array with 'tra' not 'trag'.

  6. React creates a new JSX snapshot with 'trag' as the title, calculating the event handler and getSuggestions() with that data.

  7. It also updates the screen with this new value as the title state so we can see the <h3> tag change to 'trag' (Render #2)

How do we handle this?

One way to 'fix' this, i.e. access the new title value in the current render, is to use a useEffect hook to subscribe to changes in the title state and also perform side effects. When there's a change to title, thegetSuggestions()function is run with the new value in the current render.

import "./styles.css";
import { useState, Fragment, useEffect } from "react";
import { data } from "./data";

export default function searchBooks() {
  const [title, setTitle] = useState("");
  const [suggestions, setSuggestions] = useState([]);

  const getSuggestions = () => {
    try {
      const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
          const filteredSuggestions = title
            ? data
                ?.filter((suggestion) =>
                  suggestion.title.toLowerCase().includes(title.toLowerCase())
                )
                ?.slice(0, 3)
            : [];
          resolve(filteredSuggestions);
        }, 1000);
      }).then((res) => setSuggestions(res));
      promise;
    } catch (error) {
      console.log(error);
      // send to logging service
    }
  };

  useEffect(() => {
    getSuggestions();
  }, [title]);

  function handleInputChange(e) {
    setTitle(e.target.value);
  }

  return (
    // ...
  );
}

As you can see above, getSuggestions() logs the new value of title as it is being updated via the input element and event handler.

Another simple way would also be to pass the data to be updated directly to the getSuggestions() function.

const getSuggestions = (query) => {
    try {
      const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
          const filteredSuggestions = query
            ? data
                ?.filter((suggestion) =>
                  suggestion.title.toLowerCase().includes(query.toLowerCase())
                )
                ?.slice(0, 3)
            : [];
          resolve(filteredSuggestions);
        }, 1000);
      }).then((res) => setSuggestions(res));
      promise;
    } catch (error) {
      console.log(error);
      // send to logging service
    }
};

function handleInputChange(e) {
    setTitle(e.target.value);
    getSuggestions(e.target.value);
}

A way to improve performance would be to debounce the onChange event handler in order to limit the frequency of the asynchronous requests to the API or, in our case, function calls.

Side Quest🚀

If you name a setter function without the prefix, set-, what happens?🧐

const [title, makeTitle] = useState("");

function handleInputChange(e) {
  makeTitle(e.target.value);
}

Nothing at all. It's just a naming convention😉

Recap

I hope you learned something and now understand the snapshot behavior of React in setting state variables and component rendering, and also how to address it.

Please leave a like if this was helpful. Comment also if you have questions.

Happy Coding🙌