Instant Search Params with React Server Components

Sam Selikoff

Sam Selikoff

Introduction

In React's new Server Component paradigm, routing is server-centric. Navigations in dynamic RSC apps – like those triggered by router.push in Next.js – cause React to wait on a server roundtrip before it can update the page.

One consequence of this is that URL-driven filter panels don't feel snappy out of the box.

React's new experimental useOptimistic hook is an elegant solution to this problem. It lets you merge ephemeral client-side state with server-side data, making it a perfect fit for building filter panels that respond immediately to client interactions, while falling back to the URL as their source of truth.

This demo is built with useOptimistic. Give it a shot by toggling some genres:

1000ms of latency added for demonstration.

Notice how the genre buttons respond immediately to your clicks, regardless of whether the app is idle or a server-side refresh is still under way. In fact, you can keep toggling genres while the server response is pending, and React will automatically prevent any stale updates from ever re-rendering the UI.

Once the server responds, React updates the URL and displays the new page. Navigating the browser back or forward keeps the genre buttons in sync with the UI, and reloading the page renders them in the correct initial state.

It's a beautiful example of the server and client working together to deliver a great user experience.

Show me the code!

Here's the punchline:

"use client";

import { useRouter } from "next/navigation";
import { useOptimistic, useTransition } from "react";

export default function GenresPanel({ genres }: { genres: string[] }) {
  let router = useRouter();
  let [optimisticGenres, setOptimsticGenres] = useOptimistic(genres);
  let [pending, startTransition] = useTransition();

  function updateGenres(genres: string[]) {
    let newParams = new URLSearchParams(
      genres.map((genre) => ["genre", genre])
    );

    startTransition(() => {
      setOptimsticGenres(genres);
      router.push(`?${newParams}`);
    });
  }

  return (
    <div>
      {["1", "2", "3", "4", "5"].map((genre) => (
        <label key={genre}>
          <input
            name={genre}
            type="checkbox"
            checked={optimisticGenres.includes(genre)}
            onChange={(e) => {
              let { name, checked } = e.target;
              let newGenres = checked
                ? [...optimisticGenres, name]
                : optimisticGenres.filter((g) => g !== name);

              updateGenres(newGenres);
            }}
          />
          Genre {genre}
        </label>
      ))}

      <button onClick={() => updateGenres([])}>Clear</button>

      <div>
        <p>Params (Client):</p>

        {optimisticGenres.map((genre) => (
          <p key={genre}>{genre}</p>
        ))}
      </div>
    </div>
  );
}

As soon as a checkbox is checked, we call setOptimisticGenres and navigate to a new URL with router.push. The panel (a client component) reads from optimisticGenres so it stays responsive, while our page (a server component) makes a roundtrip to the server to render the new URL.

Once the response comes back from the server and the app re-renders, useOptimistic discards any changes we made and resets itself to the new server-side data. And if we reload the browser or navigate forward or back, the panel's checkboxes reflect the URL — all without us writing any extra code.


To understand how useOptimistic works, let's try to build the panel without it.

First try: Uncontrolled checkboxes

We'll start off with the simplest approach we can think of: calling router.push in the onChange handlers of our checkboxes:

<>
  {["1", "2", "3", "4", "5"].map((genre) => (
    <label key={genre}>
      <input
        name={genre}
        type="checkbox"
        onChange={(e) => {
          let { name, checked } = e.target;

          // Add or remove this genre based on its checked state
          let newGenres = checked
            ? [...genres, name]
            : genres.filter((g) => g !== name);

          // Create a URLSearchParams instance from the new list of genres
          let newParams = new URLSearchParams(
            newGenres.map((genre) => ["genre", genre])
          );

          // Push the new search params to the URL
          router.push(`?${newParams}`);
        }}
      />
      Genre {genre}
    </label>
  ))}
</>

And this works! ...sorta.

Here's an interactive demo. Try clicking some checkboxes slowly, and observe the behavior. Then try again more quickly. Finally, see what happens when you navigate back, forward, or refresh the page on different URLs.

Give it a shot:

You should immediately notice some problems with our first attempt.

First, if you quickly click Genre 1 and Genre 2, the app will end up at /demo-3?genre=2. Genre 1 never makes it to the URL, even though it's still checked in the panel.

Second, and more importantly, the checkboxes don't track the URL. If we navigate back or forward, the checkboxes don't update, and if we reload the page, their initial state doesn't reflect the search params in the URL.

Let's tackle the URL tracking problem first.

Our checkboxes are currently uncontrolled, meaning their checked state is internal and being fully managed by the DOM. Since the source of truth for the checkboxes is really the URL's search params, let's try turning them into controlled components.

Second try: Controlled checkboxes with search params

To control our checkboxes with the URL, we'll need to get the current set of search params to each checkbox in our <GenresPanel> component.

Our <Page> component – which is a Server Component – is already massaging the raw search params into an array of strings and passing them into the panel:

export default async function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  let genres = Array.isArray(searchParams.genre)
    ? searchParams.genre
    : searchParams.genre
    ? [searchParams.genre]
    : [];

  // ...

  return (
    <div>
      <GenresPanel genres={genres} />

      {/* ... */}
    </div>
  );
}

Since our page RSC will re-render anytime the URL changes, the genres prop will also stay up-to-date. So, we should be able to use it to control our checkboxes.

Let's do that by setting their checked state using genres:

export default function GenresPanel({ genres }: { genres: string[] }) {
  let router = useRouter();

  return (
    <div>
      {["1", "2", "3", "4", "5"].map((genre) => (
        <label key={genre}>
          <input
            name={genre}
            type="checkbox"
            checked={genres.includes(genre)}
            onChange={(e) => {
              let { name, checked } = e.target;
              let newGenres = checked
                ? [...genres, name]
                : genres.filter((g) => g !== name);

              let newParams = new URLSearchParams(
                newGenres.map((genre) => ["genre", genre])
              );

              router.push(`?${newParams}`);
            }}
          />
          Genre {genre}
        </label>
      ))}
    </div>
  );
}

Okay — let's see how it works!

Try clicking the checkboxes slowly and quickly, as well as using the browser navigation. Observe the behavior.

There's definitely been some improvement!

Our checkboxes now correctly track the URL. If we click a genre and wait for the server to refresh, we can navigate back or forward, and the checkboxes update accordingly. They also render in the correct initial state if we reload the page.

But we've introduced a huge delay! Our checkboxes don't show their new checked state until the server responds with the new page.

How can we make our checkboxes responsive again?

You may be aware that Next.js has a useSearchParams() hook that client components can use, and you might (reasonably) assume that because it's a client hook, it should update immediately whenever router.push is called. So if we swapped that in instead of using the RSC's search params, we should be able to use it to control our checkboxes, but have them toggle on the client much faster.

But useSearchParams doesn't work like that.

In fact, it wouldn't change our program's behavior at all. It only updates once the server has responded, so its values will always be in sync with the search params from the latest RSC render. This again comes from the fact that in RSC, routing is server-centric.

So, there's no way for the client to "see" the next URL before the server responds. But our checkboxes are slow. We want them to reflect that new URL as soon as possible.

What if we fixed their responsiveness by making our server respond faster?

Third try: Unblocking the RSC with Suspense

The main reason our app is so slow is because our <Page> component is currently blocking on a data fetch:

export default async function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  let genres = Array.isArray(searchParams.genre)
    ? searchParams.genre
    : searchParams.genre
    ? [searchParams.genre]
    : [];

  // Fetch the content...
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return {
    /* ... */
  };
}

That single await is slowing down the whole response.

If we move the data-fetching part of our RSC into its own component, we could wrap it in a Suspense boundary:

export default async function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  let genres = Array.isArray(searchParams.genre)
    ? searchParams.genre
    : searchParams.genre
    ? [searchParams.genre]
    : [];

  return (
    <div>
      <GenresPanel genres={genres} />

      <div>
        <Suspense fallback={<p>Loading...</p>}>
          <MovieList genres={genres} />
        </Suspense>
      </div>
    </div>
  );
}

async function MovieList({ genres }: { genres: string[] }) {
  // Fetch the content...
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return (
    <>
      <p>Params (Server):</p>

      <div>
        {genres.map((genre) => (
          <p key={genre}>{genre}</p>
        ))}
      </div>
    </>
  );
}

That would unblock our server from the data fetch, letting it respond much faster with the new search params – which is all the client needs in order to update the checkboxes.

Let's give it a shot!

Hmm... doesn't seem any different. Our Suspense boundary isn't showing its fallback.

Try using the browser navigation to reload the page.

There it is!

You should see the Loading message during the first render. But the fallback never renders again during subsequent updates — which means our checkboxes are still blocked from updating until the whole page is refreshed.

What gives?

It turns out that when we call router.push, Next.js is updating our app within a transition. Transitions in React are a way to kick off or prepare an update, without disrupting the current UI. The idea is that for many types of interactions, it's better to keep the existing UI around while you're waiting for an update to be ready, rather than immediately blowing away the existing UI as soon as an update is kicked off.

And if you think about it, this behavior is exactly how normal links work in the browser. When you click a link, the browser keeps the current page rendered while it goes off and fetches the next page.

So, it makes a lot of sense that navigations in Next.js are marked as transitions.

Now, how does Suspense interact with transitions? Normally we'd expect a Suspense boundary to show its fallback if any of its children block on a promise during a re-render. But if the re-render is happening within a transition, the Suspense boundary will actually keep its existing content displayed rather than showing its fallback again. And this behavior of Suspense boundaries keeps them aligned with the spirit of transitions. The existing UI remains visible while the update is being prepared.

There's actually a somewhat tricky way we can get around this, and force our Suspense boundary to show its fallback each time we call router.push: by giving it a key.

If we key our Suspense boundary by something that changes with each re-render, that will force a brand new boundary to be mounted – and new boundaries always show their fallback if a child suspends, since its their first time rendering, and they have no existing content they could display instead.

All we need to do is find something that changes with each re-render to use as a key. Well, we've got the search params. Seems like a perfect fit!

Let's try stringifying them, and using that string as the key for our Suspense boundary:

export default async function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  let genres = Array.isArray(searchParams.genre)
    ? searchParams.genre
    : searchParams.genre
    ? [searchParams.genre]
    : [];

  return (
    <div>
      <GenresPanel genres={genres} />

      <div>
        <Suspense
          fallback={<p>Loading...</p>}
          key={JSON.stringify(searchParams)}
        >
          <MovieList genres={genres} />
        </Suspense>
      </div>
    </div>
  );
}

Fingers crossed...

Let's give it a shot:

That's much better!

The checkboxes feel responsive again, since they're no longer blocked by the slow data fetch.

But you may notice the checkboxes aren't instant. Indeed, they still don't update until our app makes a roundtrip to the server. If you open your console and throttle the network to Fast 3G, you'll see it. React still needs to go to the server to re-render our page with the new URL.

So, even though our Suspense boundary is keyed and shows its fallback during re-renders, our checkboxes are still being blocked by a server roundtrip.

Furthermore, the keyed Suspense boundary has added some unwanted behavior – the worst of which is seeing the "Loading..." fallback appear each time we click a genre. It's pretty jarring, especially compared to the normal behavior of a transition.

All this to say, adding a key to the Suspense boundary doesn't feel like a great solution to our problem.

(As an aside, if you find yourself adding keys to Suspense boundaries, there's a very good chance you're framework fighting. Suspense boundaries – like all React components – should be stable across re-renders that are semantically updates to existing UI, so that the rest of your tree's state isn't unnecessarily blown away.)


It feels like we're running out of options!

If we want our checkboxes to be controlled by the URL, but also immediately update as soon as the user clicks them, it seems like we're down to two choices:

  1. Next.js does support the native History API, which lets us update the URL immediately on the client without a server-side refresh. But, we want the rest of our app to refresh! So we'd need to manually call router.refresh in an Effect whenever the URL changes.

  2. Alternatively, we could drop down to React state and manually control our checkboxes, so the client is always the source of truth. That would keep them responsive – but then we'd also need an Effect to update our state in response to browser navigations.

Both of those seem tricky. What if there was an easier way?

Solution: useOptimistic

Let's restate our problem. The checkboxes really exist in two states:

  • When the app is settled, the server is their source of truth. Whenever the server responds with a page, the checkboxes should reflect the URL that was used to generate that page.

  • When the app is transitioning, the client is their source of truth. If we press a checkbox and trigger a server-side refresh, the client should immediately reflect our press while the app is preparing the next page. The server hasn't "seen" our press yet, but the client has. So the checkbox can reflect that in anticipation of the new page that's being prepared.

This is exactly what useOptimistic was designed for. It gives you some local React state that's seeded with server-side data, but lets you make temporary changes while your app is transitioning. Once all pending transitions settle, useOptimistic automatically discards any changes you made, and resets its value to the latest version of your server-side data.

It's a lot like Git. You fork a repo, make changes, and then eventually merge your fork back into the original repo. When you do, your local changes are thrown away, and you're back to tracking origin again.

Let's see how it works!


We'll start by reverting our page RSC back to its original state. It blocks on its data fetch, and passes the genres from the search params into our client panel component:

export default async function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  let genres = Array.isArray(searchParams.genre)
    ? searchParams.genre
    : searchParams.genre
    ? [searchParams.genre]
    : [];

  // Fetch the content...
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return (
    <div>
      <GenresPanel genres={genres} />

      {/* ... */}
    </div>
  );
}

Inside our panel, we'll call useOptimistic and seed it with genres from the server:

export default function GenresPanel({ genres }: { genres: string[] }) {
  let [optimisticGenres, setOptimisticGenres] = useOptimistic(genres);

  // ...
}

When we click a checkbox and find the new set of genres, we'll use a transition to update our optimisticGenres to the new set, and push the new URL:

export default function GenresPanel({ genres }: { genres: string[] }) {
  let [optimisticGenres, setOptimisticGenres] = useOptimistic(genres);
  let [isPending, startTransition] = useTransition();
  let router = useRouter();

  return (
    <>
      <input
        name={genre}
        type="checkbox"
        onChange={(e) => {
          let { name, checked } = e.target;
          let newGenres = checked
            ? [...optimisticGenres, name]
            : optimisticGenres.filter((g) => g !== name);

          let newParams = new URLSearchParams(
            newGenres.map((genre) => ["genre", genre])
          );

          startTransition(() => {
            setOptimisticGenres(newGenres);
            router.push(`?${newParams}`);
          });
        }}
      />
    </>
  );
}

Even though router.push internally kicks off a transition, starting our own is what lets React link our optimistic update with this specific call to router.push.

Finally, we use optimisticGenres as the source of truth for our checkboxes checked state:

<label>
  <input
    name={genre}
    type="checkbox"
    checked={optimisticGenres.includes(genre)}
    onChange={(e) => {
      let { name, checked } = e.target;
      let newGenres = checked
        ? [...optimisticGenres, name]
        : optimisticGenres.filter((g) => g !== name);

      let newParams = new URLSearchParams(
        newGenres.map((genre) => ["genre", genre])
      );

      startTransition(() => {
        setOptimisticGenres(newGenres);
        router.push(`?${newParams}`);
      });
    }}
  />
  Genre {genre}
</label>

...and that's it!

Let's try it out:

Check out all the behavior this gives us:

  • Clicking checkboxes instantly toggles their checked state. The network has zero impact on their responsiveness.
  • Navigating the browser back or forwards updates the checkboxes.
  • Refreshing the browser renders the checkboxes in the correct initial state.

On top of that, look at how our app handles multiple pending updates: if we click several checkboxes while our RSC is still being refreshed, the intermediate states are discarded. Our UI only ever reflects the latest version of our genre panel.

Not only that, but if we click the browser's back button, our UI updates to the last settled state, rather than cycling through each intermediate update. The discarded updates are never even pushed to the browser's history stack, meaning browser navigation with back, forward, and refresh will perfectly match the versions of our app that we actually saw painted to the screen.

Pretty incredible.

Showing feedback during transitions

You may have noticed that our call to useTransition returned an array with two items:

let [isPending, startTransition] = useTransition();

We used startTransition to update our optimistic state and push a new URL to the router:

startTransition(() => {
  setOptimisticGenres(genres);
  router.push(`?${newParams}`);
});

But what about isPending?

It's a boolean flag that's true while this specific transition is running, and false once it settles. And we can use it to show pending UI to the user so they know the rest of our page is being refreshed.

Now, since isPending is in scope of our client genre panel, we could use it directly in the panel's JSX. But since it's the page that's being refreshed, it'd be nice if we could use it there.

There's only one problem: our page is a Server Component, but useTransition is a hook, and hooks can only be used in Client Components.

How can we get around this?

We could move our useTransition call into React context. We'd need to add a provider component that sits above both the <GenresPanel> and our RSC's main content. Then our <GenresPanel> would need to use the context to get startTransition; and we'd need to add another client component that uses the context to get the isPending state so it can render some pending UI around our RSC's main content.

That's quite a bit of boilerplate.

Fortunately, there's a much simpler way: the :has pseudo-class from CSS.

:has is a new but well-supported CSS selector that lets you style parents based on the state of their children.

If we use the isPending flag in our genre panel to flip a data-pending attribute somewhere in our panel's return tree:

export default function GenresPanel({ genres }: { genres: string[] }) {
  let [isPending, startTransition] = useTransition();

  // ...

  return (
    <div data-pending={isPending ? "" : undefined}>
      {["1", "2", "3", "4", "5"].map((genre) => ({
        /* ... */
      }))}
    </div>
  );
}

...then we can target the pending state from our page RSC using Tailwind's has modifier:

export default async function Page() {
  // ...

  return (
    <div className="has-[[data-pending]]:animate-pulse">
      <GenresPanel genres={genres} />

      <div>
        <p>Params (Server):</p>

        <div>
          {genres.map((genre) => (
            <p key={genre}>{genre}</p>
          ))}
        </div>
      </div>
    </div>
  );
}

And now our entire page pulses during the transition!

If we combine has with Tailwind's group modifier, we can target only the main RSC content with our pulse animation:

export default async function Page() {
  // ...

  return (
    <div className="group">
      <GenresPanel genres={genres} />

      <div className="group-has-[[data-pending]]:animate-pulse">
        <p>Params (Server):</p>

        <div>
          {genres.map((genre) => (
            <p key={genre}>{genre}</p>
          ))}
        </div>
      </div>
    </div>
  );
}

Putting it all together, here's the final demo:

Our checkboxes toggle immediately, while fully respecting browser navigation.

useOptimistic has given us the full power of React on the client, letting us provide instant feedback whenever the user interacts with our page, while still managing to decorate our page component while it re-renders on the server.


I hope this post helped you develop a better intuition around useOptimistic and React transitions, and how these powerful new primitives let us compose server-side and client-side functionality together in novel and exciting ways.

The next time you're buidling some UI that seems like it sometimes uses the server as its source of truth, and sometimes the client, just think: useOptimistic!

If you'd like to look at the full source for all the demos on this page, check out the repo GitHub.

Last updated:

Get our latest in your inbox.

Join our newsletter to hear about Sam and Ryan's newest blog posts, code recipes, and videos.