— Tutorial, Frontend, JavaScript — 2 min read
With the recent release of Redux Toolkit, redux and its related libraries have become easier than ever to integrate into react applications. I've always enjoyed using redux as a state management tool because, despite its verbosity, it always boiled down to simple actions and reducers. I've stuffed many things into redux, for better or for worse, and one of the things I still feel is for the better is routing.
I don't really have any strong arguments as to why one should put routing state in redux rather than components. They both work. I just find it simpler when my routing plays nice with redux.
Rudy
is a redux-based router and the successor to redux-first-router
. At its core, it uses redux actions to manage your routing state. Actions are used to change pages, actions are used to bind to urls, actions are used to bind to components, and everything else related to routing. Once set up, this is what creating and using a route looks like in rudy
:
1// page.slice.ts2import { createAction } from '@reduxjs/toolkit';34export const toHelp = createAction('page/toHelp');56// home.component.ts7import { useDispatch } from 'react-redux';8import { toHelp } from './page.slice';910const Home = () => {11const dispatch = useDispatch()1213return (14 <div>15 <p>This is the Home page</p>16 <button onClick={() => dispatch(toHelp())}>Help</button>17 </div>18 )19}
An action creator is created and then it is dispatched in the Home
component upon a button click, which will then route the user to the Help
page (this is a simplified example).
Because the routing is based entirely on redux actions, you can dispatch route changes anywhere in your app where would normally dispatch redux actions. This can come in handy not only in components and event handlers but also in redux middleware where you might to dispatch a route change in response to an async action:
1// page.epic.ts2export const redirectToHelpEpic = (3 action$: ActionsObservable<PayloadAction>,4 state$: StateObservable<StoreState>,5): Observable<PayloadAction> =>6 action$.pipe(7 filter(storeLocationUpdateSuccess.match),8 map((_) => {9 return toHelp();10 }),11 );
In the above example, I redirect to the Help
page after a successful async update using redux-observables
. The same idea can be applied to other types of middleware like thunks
or sagas
.
Every redux app needs to configure a store, and that task has been made easier thanks to the recent addition of Redux toolkit:
1// store.ts2import { configureStore as configureReduxStore } from '@reduxjs/toolkit';3import { createRouter } from '@respond-framework/rudy';45// We'll get to these routes later6import { urlRoutes, pageReducer } from 'modules/page';78export const configureStore = () => {9 const {10 reducer: locationReducer,11 middleware: routerMiddleware,12 firstRoute,13 } = createRouter(urlRoutes);1415 const store = configureReduxStore({16 reducer: {17 location: locationReducer,18 page: pageReducer,19 },20 middleware: [routerMiddleware],21 });2223 return { store, firstRoute };24};
rudy
provides a reducer
, middleware
, and firstRoute
. The first two objects hook into the redux store while the firstRoute
is a little oddity that needs to be dispatched before the app is rendered. Plugging it into your component tree can look like this:
1// index.tsx23const { store, firstRoute } = configureStore();45function render() {6 ReactDOM.render(7 <ReduxProvider store={store}>8 <React.StrictMode>9 <App />10 </React.StrictMode>11 </ReduxProvider>,12 document.getElementById('root'),13 );14}1516store.dispatch(firstRoute()).then(() => render());
These steps setup rudy
for use in our store. Now we can create our own little redux
slice and configure the actions that rudy
will watch and bind to in order to manage routing.
Like any other redux
slice, we're going to need actions, reducers, and selectors. But in order to bind our actions to urls and components we're also going to create a couple more mapping objects:
1// modules/page/page.slice.ts2import { createSlice, createSelector, createAction } from '@reduxjs/toolkit';34// Our action creators5export const toOrders = createAction('page/toOrders');6export const toHelp = createAction('page/toHelp');7export const toSettings = createAction('page/toSettings');8export const toManagePlan = createAction('page/toManagePlan');9export const toNotFound = createAction('page/toNotFound');1011// Mapping actions to routes (used in rudy initialization)12export const urlRoutes = {13 [toOrders.toString()]: '/orders',14 [toHelp.toString()]: '/help',15 [toSettings.toString()]: '/settings',16 [toManagePlan.toString()]: '/manage-plan',17 [toNotFound.toString()]: '/not-found',18};1920// Mapping actions to components (note that the values must match the names of the components)21export const componentRoutes = {22 [toOrders.toString()]: 'Orders',23 [toHelp.toString()]: 'Help',24 [toSettings.toString()]: 'Settings',25 [toManagePlan.toString()]: 'ManagePlan',26 [toNotFound.toString()]: 'NotFound',27};2829// An array of all our action types for convenience30export const routeActionTypes = [31 toOrders.toString(),32 toHelp.toString(),33 toSettings.toString(),34 toManagePlan.toString(),35 toNotFound.toString(),36];3738// Our redux slice39const pageSlice = createSlice({40 name: 'page',41 initialState: {42 currentPage: componentRoutes[toOrders.toString()],43 },44 reducers: {},45 extraReducers: Object.fromEntries(46 routeActionTypes.map((routeActionType: string) => {47 return [48 routeActionType,49 (state: any, action) => {50 state.currentPage = componentRoutes[action.type];51 },52 ];53 }),54 ),55});5657const { reducer } = pageSlice;5859export const pageReducer = reducer;6061// selectors62export const selectPage = (state: StoreState): PageState => {63 return state.page;64};6566export const selectLocation = (state: StoreState): LocationState => {67 return state.location;68};6970export const selectCurrentPage = createSelector(71 selectPage,72 (pageState) => pageState.currentPage,73);
And now we're good to go. Our routes are synced and tracked to redux and we can use them in our components like this:
1// pages/index.ts2export { Orders } from './orders';3export { PickupAndDelivery } from './pickup-and-delivery';4export { Help } from './help';5export { Settings } from './settings';6export { ManagePlan } from './manage-plan';7export { NotFound } from './not-found';89// app.component.tsx10import React from 'react';11import { useDispatch } from 'react-redux';12import { DesignSystemProvider, Page } from '@SomeDesignSystem';13import { useSelector } from 'react-redux';1415import { selectCurrentPage } from 'modules/page';16import * as pages from 'pages';1718export const App = () => {19 const dispatch = useDispatch();2021 const currentPage = useSelector(selectCurrentPage);22 const Component = pages[currentPage];2324 return (25 <DesignSystemProvider>26 <Page>27 <Component />28 </Page>29 </DesignSystemProvider>30 );31};
The current page can be queried for and updated by using actions and selectors from the page
slice we created, and any routing-related data can be found in the location
reducer provided by rudy
, which can also be queried for as any other redux reducer.
You may want to integrate url parameters at some point in your development, and you may find that rudy
and redux
don't play nice together out of the box.
You can create actions and routes that use parameters like this:
1export const toManageLocation = createAction(2 'page/toManageLocation',3 function prepare(locationId: string) {4 return {5 payload: {6 locationId,7 },8 };9 },10);1112export const urlRoutes = {13 [toManageLocation.toString()]: '/manage-location/:locationId',14};
But Redux toolkit requires actions to have a payload
property while rudy
actions use a params
property instead when it comes to parameters. The good news is that it's all redux and it can be fixed with redux tools, namely redux middleware. I worked around this issue by creating a middleware that converts the payload
from routing-specific actions to params
so that rudy
and RTK
can play nice together again.
1const rudyActionPayloadToParamsConverter = (store: any) => (next: any) => (action: any) => {2 const shouldConvert = routeActionTypes.includes(action?.type) && !!action?.payload;34 if (shouldConvert) {5 const nextAction = {6 type: action?.type,7 params: action?.payload,8 };9 return next(nextAction);10 }1112 return next(action);13};1415const store = configureReduxStore({16 reducer: {17 ...18 },19 middleware: [20 rudyActionPayloadToParamsConverter,21 routerMiddleware,22 epicMiddleware,23 ]24 });
Routing state can easily be integrated into and managed by redux thanks to rudy
. If you're looking for a router designed for redux, I highly recommend this library. Though the documentation may be lacking and it's popularity is nowhere near more popular routing libraries, it works just fine.