How to control a React component with the URL

Sam Selikoff

Sam Selikoff

Introduction

"Can we make this screen shareable via the URL?"

It's a common feature request. Surprisingly, it also leads to one of the most common causes of bugs in React applications.

Take this searchable table. If you've used React before, you've probably built something just like it:

export default function Page() {
  let [search, setSearch] = useState(''); 
  let { data, isPlaceholderData } = useQuery({ 
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`); 
      let data = await res.json();

      return data;
    },
    placeholderData: (previousData) => previousData, 
  });

  return (
    <>
      <Heading>Your team</Heading>

      <InputGroup>
        {isPlaceholderData ? <Spinner /> : <MagnifyingGlassIcon />}
        
        <Input
          value={search} 
          onChange={(e) => setSearch(e.target.value)} 
          placeholder="Find someone..."
        />
      </InputGroup>

      {!data ? (
        <Spinner />
      ) : (
        <Table>
          <TableHead>
            <TableRow>
              <TableHeader>Name</TableHeader>
              <TableHeader>Email</TableHeader>
              <TableHeader>Role</TableHeader>
            </TableRow>
          </TableHead>
          <TableBody>
            {data.people.map((person) => ( 
              <TableRow key={person.id}>
                <TableCell>{person.name}</TableCell>
                <TableCell>{person.email}</TableCell>
                <TableCell>{person.role}</TableCell>
              </TableRow> 
            ))}
          </TableBody>
        </Table>
      )}
    </>
  );
}

It's built using React Query to fetch the table's data from the server, and it has an input that updates some local React state, which re-fires the query to fetch new search results for the table.

And it works great! But our table isn't shareable.

Try typing in "john", then hitting Reload:

Poof!

Since all our state is in React, the search text and table data don't survive page reloads.

And this is where the feature request comes in:

"Can we make this screen shareable via the URL?"

Well, we've already done all this work to build the table. All we need to do is update the URL to stay in sync whenever our search state changes...

Maybe we can pull it off with useEffect?

Syncing the URL with React state

Since we have the search text in React state, we should be able to run an effect every time it changes:

export default function Home() {
  let [search, setSearch] = useState('');
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  useEffect(() => { 
    // Run some code every time `search` changes
  }, [search]); 

  return (
    // ...
  );
}

Let's update the URL there!

We're using Next.js, so we can grab the router from useRouter and the current path from usePathname, and call router.push to update the URL with the latest search text:

export default function Home() {
  let [search, setSearch] = useState('');
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  let router = useRouter(); 
  let pathname = usePathname(); 

  useEffect(() => {
    if (search) {
      router.push(`${pathname}?search=${search}`);
    }
  }, [pathname, router, search]);

  return (
    // ...
  );
}

Let's try it out.

Try typing "john" in the search box:

The URL is updating!

Now, try hitting Reload.

Hmm... our UI is out of sync. The URL still shows ?search=john, but the search box and table aren't reflecting that.

We need to use the URL to seed our search state's initial value.

Let's grab the useSearchParams hook and make that change:

export default function Home() {
  let searchParams = useSearchParams(); 
  let [search, setSearch] = useState(searchParams.get('search') ?? ''); 
  
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  let router = useRouter();
  let pathname = usePathname();

  useEffect(() => {
    if (search) {
      router.push(`${pathname}?search=${search}`);
    }
  }, [pathname, router, search]);

  // ...
}

Ok – let's try it out.

Try typing "john" again, and then pressing Reload:

Seems to be working!

But we forgot one more thing. Try pressing the Back button.

...whoops!

The table isn't updating. The Back and Forward buttons are changing the URL, but they're not updating our React state.

Maybe we should add another useEffect that watches for changes to searchParams, and updates the search state whenever they change?


We're heading down a bad road.

And the fundamental reason why is that we now have two sources of truth for the search text:

  1. The search state from React
  2. The ?search query param from the URL

Duplicate state is usually the culprit for these kinds of frustrating bugs. If we can eliminate the duplicated state, our table and URL should stay in sync.

But which one should we delete?

Conceptually, the URL sits "above" our React app. It's external to our code, and us as application developers don't really have control over it. Users can change the URL on their own using the address bar or navigation controls.

That means the ?search query param is really the source of truth for the search text. We should eliminate the React state from our code, and instead derive the search text from the URL.

Hoisting the search text to the URL

Let's start by undoing our first attempt.

We'll go back to what we had before we started messing with the URL:

export default function Home() {
  let [search, setSearch] = useState('');
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  return (
    <>
      {/* ... */}
      
      <Input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Find someone..."
      />
    </>
  );
}

Now, let's refactor to add URL support.

First, since the URL has become the source of truth for the search text, let's delete our React state and derive search from the search params instead:

export default function Home() {
  let searchParams = useSearchParams(); 
  let search = searchParams.get('search') ?? ''; 
  
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  return (
    <>
      {/* ... */}
      
      <Input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Find someone..."
      />
    </>
  );
}

Second, whenever we type into our input, we want it to update the URL instead of setting state.

We'll use exactly the same logic we had in our effect:

export default function Home() {
  let searchParams = useSearchParams();
  let search = searchParams.get('search') ?? '';
  
  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: async () => {
      let res = await fetch(`/api/people?search=${search}`);
      let data = await res.json();

      return data as Response;
    },
    placeholderData: (previousData) => previousData,
  });

  return (
    <>
      {/* ... */}
      
      <Input
        value={search}
        onChange={(e) => {
          let search = e.target.value;
          
          if (search) {
            router.push(`${pathname}?search=${search}`);
          }
        }}
        placeholder="Find someone..."
      />
    </>
  );
}

Let's give it a shot.

Type "john", press Reload, and then try the Back and Forward buttons:

Look at that!

With just those two changes, we get all this behavior:

  • Typing in the search box updates the URL
  • The Refresh, Back, and Forward buttons work; and
  • The URL, search box, and table all stay in sync

There's one final case we missed: if we try to clear the text from the search box, nothing happens.

Let's update our event handler to reset the URL if the search text is empty:

<Input
  value={search}
  onChange={(e) => {
    let search = e.target.value;
    
    if (search) {
      router.push(`${pathname}?search=${search}`);
    } else { 
      router.push(pathname); 
    }
  }}
  placeholder="Find someone..."
/>

Here's our final demo:

No effects, no juggling multiple states to keep them in sync, and no bugs.

Best of all, this version of the code reads just as simply as the original state-controlled version.

A single source of truth

You're probably familiar with the concept of lifting state up in React. If you have two components that need the same piece of state, instead of duplicating it, you hoist it up to the lowest common parent, and then pass it into each child component as a prop.

Giving each piece of dynamic data in your app a single source of truth is how React can guarantee that your UI stays consistent.

But there's also dynamic data that lives outside of our React app. The URL is one example, but there are many others: the data that lives in our database, the current time on our user's device, and even whether they're using dark mode or light mode.

As our app changes, it's common for state that started out only existing inside of React to move to one of these external sources. Maybe you add a dark mode setting that each user can persist to your database. Or, you decide to put part of a screen's data into the URL.

As soon as this happens, the same concept of lifting state up applies — but instead of hoisting the state up to a parent React component, you hoist it up outside of your app and into the external system. And instead of passing it down to your app as a prop, you read it using whatever interface that external system gives you; or, more likely, by using an API from a library or framework, like the useSearchParams hook from Next.js.

Learning how to spot duplicated sources of truth is a big step in leveling up as a React developer. The next time you find yourself fighting a bug that has some confusing useEffect code behind it, instead of trying to fix the edge case by adding one more branch of logic or introducing another effect, instead:

  • Pause, and take a step back from the details of the effect code
  • See if the effect is setting some state
  • Check to see whether that state is already represented in some other component or external system, and
  • If it is, eliminate it

Those pesty bugs will melt away, and you'll be left with code that's easier to understand and change in the future.


There's more to say about state hoisting in React, which is why I'm working on a new course where I'll be able to cover it in even more detail.

It's called Advanced React Component Patterns, and in addition to this topic, I'll be talking about other core patterns in React like:

  • Controlled and uncontrolled components
  • Unstyled components
  • Compound components
  • Render props
  • Declarative interfaces, and
  • Recursion

If you enjoyed this post, I think you're gonna love the course.

Check out more details over on the course page:

og-image v2.png

And thanks for reading!

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.