layout: "../layouts/BlogPost.astro" title: "URL-driven state in React" slug: url-driven-state-in-react description: "" added: "Sep 28 2024"
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.
useSearchParams
hook is sensitive to URL changes)search
state will not automatically update to reflect the new URL, because useState
doesn't re-initialize on rerenders.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:
search
state from React?search
query param from the URLUsers 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: