Three React Router v4 Recipes I Inherit With Old Codebases
RRv4 still survives in long-running codebases. Three recipes I keep paged in: programmatic navigation via withRouter, query parsing, and a custom history singleton for non-React callers.
By @freyadiallo
March 3, 2026
·
Updated August 12, 2026
1,002 views
31
4.1 (11)
// RRv4 puts the router API on props (not on context, not on a hook). To
// navigate from a callback, you need access to props.history, which means
// the component is inside a Route render or wrapped in withRouter. The
// withRouter HOC injects { history, match, location } into ownProps.
// Stand-in router. Real RRv4 ships a BrowserHistory backed by the History API.
function createRouter(initialPath) {
let path = initialPath || '/';
const listeners = [];
return {
history: {
push: (to, state) => {
path = to;
listeners.forEach((l) => l({ pathname: to, state, action: 'PUSH' }));
},
replace: (to, state) => {
path = to;
listeners.forEach((l) => l({ pathname: to, state, action: 'REPLACE' }));
},
goBack: () => listeners.forEach((l) => l({ pathname: path, action: 'POP' })),
listen: (cb) => { listeners.push(cb); return () => { const i = listeners.indexOf(cb); if (i >= 0) listeners.splice(i, 1); }; },
},
location: { get pathname() { return path; } },
match: { params: {}, path: '/' },
};
}
function withRouter(Wrapped, router) {
return function WithRouter(ownProps) {
const merged = Object.assign({}, ownProps, {
history: router.history,
location: router.location,
match: router.match,
});
return Wrapped(merged);
};
}
// A consumer that needs to navigate from a button click.
function LoginForm(props) {
return {
type: 'form',
props: {
onSubmit: (event) => {
if (event && event.preventDefault) event.preventDefault();
// Here is the whole point: imperative navigation from inside the handler.
props.history.push('/dashboard', { from: 'login' });
},
},
children: ['login form. clicked? ' + (props.history ? 'yes' : 'no')],
};
}
const router = createRouter('/login');
const RoutedLogin = withRouter(LoginForm, router);
const events = [];
router.history.listen((evt) => events.push(evt));
const form = RoutedLogin({});
console.log('initial render:', form.children[0]);
console.log('current pathname:', router.location.pathname);
form.props.onSubmit({ preventDefault: () => {} });
console.log('after submit, pathname:', router.location.pathname);
console.log('history events:', events);
// goBack uses the same history reference. Useful for cancel buttons.
router.history.replace('/dashboard/settings');
router.history.goBack();
console.log('all events:', events.map((e) => e.action + '->' + e.pathname));Programmatic navigation in RRv4 is the recipe everyone needs and nobody can find in the docs on the first pass. The component must have access to props.history to call history.push, which means either the component is rendered inside a <Route render={...} /> (where router props are passed automatically) or it is wrapped in withRouter (which injects them via context). I prefer withRouter for any deeply-nested form because the consumer stays a regular function component and the navigation is trivially testable: pass a stub history in the unit test, assert history.push was called with the right arguments. The state argument to push(to, state) is the second-most-useful piece: a small object you tuck into the location stack so the destination route can read context like from: 'login' without a query string.
// RRv4 dropped query parsing in v4 "to keep the bundle small". The intended
// replacement is URLSearchParams or the qs library. The recipe is a tiny
// helper that takes location.search and returns a typed object, plus the
// inverse to write a query back. Both are five lines each.
// Helpers. Real apps usually park these in a routing utils file.
function parseQuery(search) {
if (!search) return {};
const out = {};
const params = new URLSearchParams(search.charAt(0) === '?' ? search.slice(1) : search);
for (const [key, value] of params) {
// Multi-value (?tag=a&tag=b) collapses into an array; single values stay scalar.
if (key in out) {
const existing = out[key];
out[key] = Array.isArray(existing) ? existing.concat(value) : [existing, value];
} else {
out[key] = value;
}
}
return out;
}
function stringifyQuery(obj) {
const params = new URLSearchParams();
for (const key of Object.keys(obj)) {
const value = obj[key];
if (value == null) continue;
if (Array.isArray(value)) value.forEach((v) => params.append(key, String(v)));
else params.set(key, String(value));
}
const out = params.toString();
return out ? '?' + out : '';
}
// Walk through. Imagine props.location.search arriving on a search results page.
const search = '?q=react+hooks&page=2&tag=useEffect&tag=useReducer';
const parsed = parseQuery(search);
console.log('parsed:', parsed);
console.log('q ->', parsed.q);
console.log('page ->', parsed.page, '(string! coerce to Number on read)');
console.log('tag ->', parsed.tag, '(array because tag appeared twice)');
// Round trip.
const nextSearch = stringifyQuery(Object.assign({}, parsed, { page: '3' }));
console.log('next search:', nextSearch);
const rebuilt = parseQuery(nextSearch);
console.log('rebuilt:', rebuilt);
// The withRouter integration. mapState-style projection of search params.
function useQuery(props) {
return parseQuery(props.location && props.location.search);
}
// Stand-in props.location for demo.
const props = { location: { search: '?q=cats&sort=likes' } };
console.log('hook-shaped extract:', useQuery(props));
// Gotcha: every value is a string. Build a coerce step at the read site so
// the rest of your app does not pass strings around as numbers and break.
function coerceSearchPage(props) {
const q = parseQuery(props.location.search);
return {
query: q.q || '',
page: Number(q.page) || 1,
tags: Array.isArray(q.tag) ? q.tag : (q.tag ? [q.tag] : []),
};
}
console.log('typed page slice:', coerceSearchPage({ location: { search } }));Three reasons I keep this in dotfiles. URLSearchParams is built into every browser and Node 18+, so the parsing has zero dependencies. The multi-value array-collapse is what makes tag-style filters work without a separate library: a URL like ?tag=a&tag=b parses to { tag: ['a', 'b'] }, and the round trip via stringifyQuery regenerates an equivalent string. The coercion step at the bottom is the load-bearing habit: every value out of URLSearchParams is a string, so a page-number param needs an explicit Number(q.page) || 1 somewhere or you will eventually see "2" + 1 === "21" show up in production. For RRv5 / RRv6 / Next this is mostly the same recipe, just attached to a different location shape.
// The recipe nobody mentions until you need it: imports outside the React
// tree (a saga, an axios interceptor, a third-party SDK callback) cannot get
// at history via withRouter. The fix is to create a single browser history
// instance and import it from anywhere. <Router> consumes the same instance,
// so push() from the saga and from a connected component drive the same UI.
// In real RRv4 this is `import createHistory from 'history/createBrowserHistory'`.
// We sketch the shape here so the snippet runs.
function createBrowserHistory(initialPath) {
let path = initialPath || '/';
const listeners = [];
return {
get location() { return { pathname: path, search: '', state: this._state || null }; },
push: function (to, state) {
path = to;
this._state = state || null;
listeners.forEach((l) => l({ pathname: to, action: 'PUSH', state }));
},
replace: function (to, state) {
path = to;
this._state = state || null;
listeners.forEach((l) => l({ pathname: to, action: 'REPLACE', state }));
},
listen: function (cb) {
listeners.push(cb);
return function () { const i = listeners.indexOf(cb); if (i >= 0) listeners.splice(i, 1); };
},
};
}
// File: src/history.js (the singleton)
const history = createBrowserHistory('/');
// File: src/api.js (axios-style interceptor that redirects on 401)
function makeApiClient(httpStub) {
return {
request: function (config) {
return httpStub(config).catch((err) => {
if (err && err.status === 401) {
history.push('/login', { from: config.url });
}
throw err;
});
},
};
}
// File: src/saga.js (saga that navigates after a successful action)
function onCheckoutComplete(orderId) {
history.push('/orders/' + orderId, { fromCheckout: true });
}
// File: src/index.js (<Router history={history}> consumes the same singleton)
// In real code: <Router history={history}><App /></Router>
// Here we just listen so we can verify the events.
const seenEvents = [];
history.listen((evt) => seenEvents.push(evt));
// Drive both code paths. The router would be re-rendering in response to each.
const api = makeApiClient((cfg) => Promise.reject({ status: 401, url: cfg.url }));
api.request({ url: '/users/me' }).catch(() => {});
setTimeout(() => {
onCheckoutComplete('A-1738');
setTimeout(() => {
console.log('all events seen by the router:');
seenEvents.forEach((e) => console.log(' -', e.action, e.pathname, JSON.stringify(e.state)));
console.log('current location:', history.location);
}, 5);
}, 5);The trick is that RRv4 wires <Router history={history}> to a single history object, and you can import that same object from anywhere in your codebase. A 401 interceptor in axios calls history.push('/login', { from: requestUrl }), the router subscribes to the listen events, and the UI re-renders the login screen, all without the interceptor ever touching React. The same pattern lets a saga finish a checkout and route to the order page in one line, which is exactly the kind of side-effect that lives outside the component tree and would otherwise need an awkward dispatch plus a route effect. The downside is that the singleton is a hidden module-level dependency: tests that exercise the interceptor have to either reset it or stub it. RRv5 keeps this exact pattern; RRv6 deprecates it in favour of useNavigate plus router-managed state.
