State should live where users expect it
Search pages accumulate state quickly: terms, filters, sort order, pagination, and selected views. Moving all of it into a global store can make the implementation convenient while making the product less useful. Refreshing loses context. Sharing a link loses the view. The back button stops telling the truth.
When state changes what the page represents, the URL is often its natural home. A filtered view becomes bookmarkable, support can reproduce what a user sees, and browser navigation works without custom history machinery.
Parse once at the boundary
URL parameters are untrusted strings, not application state. Parse them into a typed model at the route boundary, apply defaults there, and let components consume the normalized result. Serialization should happen through the same contract in reverse.
That boundary keeps query hooks, grids, and controls from each inventing their own interpretation. It also makes invalid combinations easier to test because the normalization logic is small and deterministic.
type SearchState = {
query: string;
page: number;
sort: 'newest' | 'oldest';
};
function parseSearchState(params: URLSearchParams): SearchState {
const page = Number(params.get('page'));
const sort = params.get('sort');
return {
query: params.get('q')?.trim() ?? '',
page: Number.isInteger(page) && page > 0 ? page : 1,
sort: sort === 'oldest' ? 'oldest' : 'newest',
};
}Keep temporary interaction temporary
Not every keystroke needs a history entry. A local input value can remain local while the committed search value updates the URL. Replace history for rapid adjustments; push history when the user makes a meaningful navigation choice.
Good state architecture is less about choosing one store and more about giving each kind of state the right lifetime.