Before you useState

Published
2021-06-11
Tags
ReactWebDev

Imagine, you are on this amazing website that has lots of filters and you just nailed down a combination that yields perfect results and you would like to share it with your partner. You hit the "Share" button and send it over. Then the other person opens it only to see... the default page instead of filtered results. Everyone hates it! Yet we, Frontend developers, are the ones who screwed up by treating our applications' state as something that only belongs to the application and burring it in useState calls or in the Redux store. Luckily, we are the ones who can fix it!

Simply useState

While implementing a filter for a list of items based on a user input, most of you would probably do something like without even thinking twice (at least I did so many times!):

import * as React from "react";

const options = ["Apple", "Banana", "Cherry"];

export default function App() {
  const [query, setQuery] = React.useState(""); // set the initial state
  const results = options.filter((option) => option.includes(query)); // filter results using the state
  const handleChange = (e) => setQuery(e.target.value); // update the state based on the new value
  return (
    <>
      <input type="search" value={query} onChange={handleChange} />
      <ul>
        {results.map((result) => (
          <li key={result}>{result}</li>
        ))}
      </ul>
    </>
  );
}

Open on CodeSandbox

This does the job and gives you the user interface we wanted but the state is now not accessible by the user. You can't share the URL with another person and they won't be able to see what you saw.

Sharing the state with the user

People start using your UI and get annoyed by the fact links aren't sharable. So you decide to implement this feature on top of the existing code base. It will probably will look like this:

import * as React from "react";

const options = ["Apple", "Banana", "Cherry"];

export default function App() {
  const queryParams = new URLSearchParams(window.location.search); // get query string from the location
  const [query, setQuery] = React.useState(queryParams.get("query") ?? ""); // set the initial state to it
  const results = options.filter((option) => option.includes(query)); // filter results using the state
  const handleChange = (e) => setQuery(e.target.value); // update the state based on the new value
  return (
    <>
      <input type="search" value={query} onChange={handleChange} />
      <ul>
        {results.map((result) => (
          <li key={result}>{result}</li>
        ))}
      </ul>
    </>
  );
}

Open on CodeSandbox

Better! We can parse the URL and set the application state to reflect it but it actually doesn't update the URL as you change the input's value while typing. Let's fix it!

Reacting to the user input

import * as React from "react";

const options = ["Apple", "Banana", "Cherry"];

export default function App() {
  const queryParams = new URLSearchParams(window.location.search); // get query string from the location
  const [query, setQuery] = React.useState(queryParams.get("query") ?? ""); // set the initial state to it
  const results = options.filter((option) => option.includes(query)); // filter results using the state
  const handleChange = (e) => setQuery(e.target.value); // update the state based on the new value

  // Now that we have the new state, let's sync it with location
  React.useEffect(() => {
    // Calculate new URL based on the state
    queryParams.set("query", query);
    const newURL = "?" + queryParams.toString();
    // Update the URL in the location
    window.history.pushState({}, undefined, newURL);
  }, [queryParams, query]);

  return (
    <>
      <input type="search" value={query} onChange={handleChange} />
      <ul>
        {results.map((result) => (
          <li key={result}>{result}</li>
        ))}
      </ul>
    </>
  );
}

Open on CodeSandbox

Phew! It works but look at that code! But wait, maybe we could do better?

Use URL instead of useState

Think about what we're trying to do:

  1. Derive the state from the URL
  1. Set it as a default value of the useState call
  1. Update the React's state in the onChange event handler using the setter function
  1. Derive the new URL in the useEffect
  1. Set the location to the new URL so it's in sync with the UI

What if we would treat the URL as our state container? This way we could bypass the local state completely. Here is the updated algorithm.

  1. Derive the state from the URL
  1. Update the location in onChange callback to keep it in sync with the UI
import * as React from "react";
import { navigate } from "@reach/router";

const options = ["Apple", "Banana", "Cherry"];

export default function App() {
  const queryParams = new URLSearchParams(window.location.search); // get query string from the location
  const query = queryParams.get("query") ?? ""; // get the query value
  const results = options.filter((option) => option.includes(query)); // filter results using the state
  const handleChange = (e) => {
    queryParams.set("query", e.target.value); // update the state based on the new value
    // Calculate new URL based on the state
    const newURL = "?" + queryParams.toString();
    // Update the URL in the location
    navigate(newURL);
  };

  return (
    <>
      <input type="search" value={query} onChange={handleChange} />
      <ul>
        {results.map((result) => (
          <li key={result}>{result}</li>
        ))}
      </ul>
    </>
  );
}

Open on CodeSandbox

Much simpler! But most importantly, not only we improved our code, but also we made our application more accessible: each filter result is now sharable using a simple link!

Final result

Gotchas

There are a few gotchas I've discovered while implementing my state this way:

  1. Although browsers' native History API gives a simple way of modifying the state using pushState(), it won't trigger a re-render of the React app. That's why in my last example I use @reach/router. Since most popular React frameworks like Next.js or Gatsby already have a router as a dependency, I don't consider this a problem.
  1. Second problem is: updating the location via a router will scroll the page to the top by default in most browsers. Most of the time it shouldn't be a problem since this is desired to see top results. Depending on the layout of the page and device's resolution, though, it can be annoying. In this case there are ways of disabling it:
    1. Next.js
      const handleChange = (event) => {
        router.replace(
          {
            query: {
              q: event.target.value,
            },
          },
          undefined,
          {
            scroll: false,
            shallow: true,
          }
        )
      }
      Gatsby
      export const shouldUpdateScroll = ({ routerProps: { location } }) => {
       return location.search === "" // Scroll to top only when we're not searching
      }
  1. Last but not least: changing the URL will re-render the whole application which can cause some performance problems for bigger and more complex applications. In this case, synchronizing the URL with the state is the only solution for now. With the concurrent mode, though, it might become much less of a problem and that's how I think frameworks should deal with complexity: developers should write the most idiomatic code and let frameworks do optimizations under the hood.

Conclusion

Next time, before you useState, stop for a second and think about your state. Most applications' state deserves to be shared with your users, so treat it as something public and put it into URL from the beginning. It will make your app more accessible and easy to use and it will make the code much simpler. A win-win situation!

P.S.: Even if you can't implement the final solution for some reason now, I still encourage you to think about the URL as an additional user interface to interact with the website and expose as much as possible to allow such interactions.