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>
</>
);
}
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>
</>
);
}
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>
</>
);
}
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:
- Derive the state from the URL
- Set it as a default value of the
useState
call - Update the React's state in the
onChange
event handler using the setter function - Derive the new URL in the
useEffect
- 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.
- Derive the state from the URL
- Update the
location
inonChange
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>
</>
);
}
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:
- 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. - 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:
Prevent scroll in Next.js
const handleChange = (event) => {
router.replace(
{
query: {
q: event.target.value,
},
},
undefined,
{
scroll: false,
shallow: true,
}
)
}
Prevent scroll in Gatsby
export const shouldUpdateScroll = ({ routerProps: { location } }) => {
return location.search === "" // Scroll to top only when we're not searching
}
- 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.
Top comments (1)
Great read. Thanx.