仓库源文站点原文


layout: "../layouts/BlogPost.astro" title: "URL-driven state in React" slug: url-driven-state-in-react description: "" added: "Sep 28 2024"

tags: [react, code]

This post is my learning notes from the article How to control a React component with the URL.

When you build a searchable table, you may have code below.

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..."
      />
      <Table>...</Table>
    </>
  );
}

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?" 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 searchParams = useSearchParams(); 
  let [search, setSearch] = useState(searchParams.get('search') ?? ''); 

  let { data, isPlaceholderData } = useQuery({
    // ...
  });

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

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

  // ...
}

Seems to be working. But we forgot one more thing. The Back and Forward buttons are changing the URL, but they're not updating our React state. The table isn't updating.

It's important to note that the useState hook only sets the initial state. When the component reruns due to URL changes, the useState call doesn't update the existing state.

To fix this, you could add another effect that updates the search state when the URL changes:

useEffect(() => {
  setSearch(searchParams.get('search') ?? '');
}, [searchParams]);

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

Users can change the URL on their own using the address bar or navigation controls. 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.

Let's delete our React state and derive search from the search params instead. Then, whenever we type into our input, we want it to update the URL instead of setting state.

export default function Home() {
  let searchParams = useSearchParams();
  let search = searchParams.get('search') ?? '';

  let { data, isPlaceholderData } = useQuery({
    queryKey: ['people', search],
    queryFn: ...
  });

  return (
    <>      
      <Input
        value={search}
        onChange={(e) => {
          let search = e.target.value;

          if (search) {
            router.push(`${pathname}?search=${search}`);
          } else { 
            router.push(pathname); 
          }
        }}
      />
      <Table>...</Table>
    </>
  );
}

This version of the code works well. No effects, no juggling multiple states to keep them in sync, and no bugs.

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: