This solution might be more complicated than you'd like since you already have code in place but here is how i've solved this problem without adding 2 way binding and extra libraries that everyone seems to love for these kinds of issues.
Shifting your philosophy and treating your history/url as your filter state will allow you to use all the uni directional patterns you enjoy. With the url as your new filter state, attaching an effect to it will let you trigger effects such as syncing your app state to the url, fetching, ect. This lets your standard navigation features such as links, back and forth, ect work for free since they will simply be filtered through the effect. Assuming youre using the standard react-router/redux stack the pattern might look something like this, but can be adapted to use whatever you have on hand.
const dispatch = useDispatch();
const location = useLocation();
const parse = (search) => {
// parse search parameters into your filter object applying defaults ect.
};
useEffect(async () => {
const filters = parse(location.search);
dispatch({ type: 'SEARCH_START', payload: filters }); // set spinner, filters, ect.
const response = await fetch(/* your url with your filters */);
const results = await response.json();
dispatch({ type: 'SEARCH_END', payload: results });
// return a disposer function with a fetch abort if you want.
}, [location.search]);
This effect will parse and dispatch your search actions. Notice how its reading values directly from location.search, parsing them, and then passing those values off to redux or whatever state management you use as well as fetching.
To handle your filter update logic, you search actions would just need to push history. This will give you unidirectional flow, keeps the results in sync with the url, and keeps the url in sync with the users filters. You are no longer updating filters directly, state must flow in one direction.
const useFilters = () => {
const serialize = (filters) => {
// exact opposite of parse. Remove default filter values or whatever you want here.
// return your new url.
};
const history = useHistory();
const filters = useSelector(selectFilters); // some way to find your already parsed filters so you can add to them.
return {
sortBy: (column) => history.push(serialize({ ...filters, sortBy: column })),
search: (query) => history.push(serialize({ ...filters, query })),
filterByShipping: (priority) => {} // ect,
filterByVendor: (vendor) => {} // blah blah
};
}
Above is an example of a filter api in hook form. With useFilters() you can use the returning functions to change the url. The effect will then be triggered, parse the url, trigger a new search, and save the parsed filter values that you can use in your other components.
The parse and serialize functions simply convert a value from query string to filters and back. This can be as complex or as simple as you need it to be. If you are already using a query string library, it could be used here. In my projects they typically parse short keys such as 'q' for query and return a mono typed filter value with defaults for things like sort order, if they are not defined. The stringify/serialize would do the opposite. It'll take the filters, convert them to short keys, remove nulls and defaults and spit out a search url string I can use for any urls/hrefs/ect.