Redux structure for large apps
After the post that demonstrates the need for external state management, and after the unpublished post comparing Redux MobX & the Flux architecture, I got to the next step, checking how can Redux scale to large applications, since all the examples are on mini-apps, and Redux brings a lot of boilerplate code that can get messy for large applications.
The app is a simple example of a list of objects, and an active object.
Pure React Baseline
The first baseline demonstrating a basic app structure. The first implementation is a standalone React app with no state management tool.
The Item object:
class Item { id: number title: string constructor(title){ this.id = Math.floor(Math.random()*10000; this.title = title; } }
Main app:
const App = ()=> { const [items,setItems] = React.useState<Array<Item>>([new Item('First path')]); const [activeItem,setActiveItem] = React.useState<Item|null>(items[0]); const addNewPath = () => { const newPath = new Item('one more path'); setItems([...items,newPath]); setActiveItem(newPath); } const resetList = () => { setItems([]); setActiveItem(null); } const setMe = async (id)=>{ setActiveItem(items.find(p => p.id == id)); } return <div> <ul>{ items.map(path => <li key={path.id} onClick={()=>setMe(path.id)}>{path.title}</li>) }</ul> <button onClick={()=>addNewPath()}>Add New</button> <button onClick={()=>resetList()}>Reset</button> <ActiveItem item={activeItem} /> </div> }
And the ActiveItem component:
type ActiveItemProp = { item: Item } const ActiveItem:React.FC<ActiveItemProp> = ({item}) => { if(item == null) return null return <p>Active: {item.title}</p> }
Simple Redux implementation:
Changing the data structure from list to state with a normalised data structure:
state = { items: { 123: {'id': 123, 'title': 'bla bla'}, 321: {'id': 321, 'title': 'blo blo'}, }, activeItem: 123 }
Create the Redux items:
const initialState = {items:{},activeItem:null} function itemsReducer(state = initialState, action){ switch(action.type){ case 'AddItem': const items = {...state.items}; items[action.payload.item.id] = action.payload.item; return {items: items,activeItem: action.payload.item.id}; case 'ResetList': return {items:{},activeItem:null}; case 'SetActive': return {...state, activeItem: action.payload.id} } return state; } const store = Redux.createStore(itemsReducer); const mapStore = (state)=>{ return state; } const App = ReactRedux.connect(mapStore)(AppWithRedux)
And the React modifications:
ReactDOM.render(<ReactRedux.Provider store={store}><App /></ReactRedux.Provider>, document.querySelector('#app')) const AppWithRedux = ({items,activeItem})=> { const addNewPath = () => { const newItem = new Item('one more path: '+ Math.floor(Math.random()*10000)); store.dispatch({type:'AddItem', payload:{item:newItem}}) } const resetList = () => { store.dispatch({type:'ResetList'}) } const setMe = async (id)=>{ store.dispatch({type:'SetActive',payload: {id:id}}) } const itemsList = []; Object.entries(items).forEach(a=> itemsList.push(a[1])) return <div> <ul>{ itemsList.map(item => <li key={item.id} onClick={()=>setMe(item.id)}>{item.title}</li>) }</ul> <button onClick={()=>addNewPath()}>Add New</button> <button onClick={()=>resetList()}>Reset</button> <ActiveItem item={items[activeItem]} /> </div> }
Large Redux app structure
#1 split the reducer:
The first thing we will want on a large app is to split the store into different files / sections:
function itemsReducer(state = {}, action){ switch(action.type){ case 'AddItem': const items = {...state}; items[action.payload.item.id] = action.payload.item; return items; case 'ResetList': return {}; } return state; } function currentStateReducer(state = {activeItem: null}, action){ switch(action.type){ case 'SetActive': return { activeItem: action.payload.id} } return state; } const appReducer = Redux.combineReducers({ items: itemsReducer, currentState: currentStateReducer }); const store = Redux.createStore(appReducer);
#2 change the mapper method:
const mapStore = (state)=>{ return {items:{...state.items},activeItem:state.currentState.activeItem}; }
#3 Use a well defined actions list:
enum ActionTypes { AddItem, ResetList, SetActive } function itemsReducer(state = {}, action){ switch(action.type){ case ActionTypes.AddItem: const items = {...state}; items[action.payload.item.id] = action.payload.item; return items; case ActionTypes.ResetList: ... ... const AppWithRedux = ({items,activeItem})=> { const addNewPath = () => { const newItem = new Item('one more path: '+ Math.floor(Math.random()*10000)); store.dispatch({type:ActionTypes.AddItem, payload:{item:newItem}}) } const resetList = () => { store.dispatch({type:ActionTypes.ResetList}) } ... ...
#4 Replace piping with Redux selectors
Split the mapping method into two. And now instead of returning the current item id I can return the actual item
const appStore = (state)=>({...state.items}); const App = ReactRedux.connect(appStore)(AppWithRedux) const currentStateStore = (state)=>({item:state.items[state.currentState.activeItem]}) const ActiveItem = ReactRedux.connect(currentStateStore)(ActiveItemWithRedux)
And remove the piping from React:
const AppWithRedux = (items)=> { ... ... <button onClick={()=>addNewPath()}>Add New</button> <button onClick={()=>resetList()}>Reset</button> <ActiveItem /> </div> } const ActiveItemWithRedux:React.FC<ActiveItemProp> = ({item}) => { if(item == null) return null return <p>Active: {item.title}</p> }
#5 Set the action as well defined actions list:
const ItemActions = { addNew: newItem => store.dispatch({type:ActionTypes.AddItem, payload:{item:newItem}}), reset: () => store.dispatch({type:ActionTypes.ResetList}) } const CurrentStateActions = { setActive : id => store.dispatch({type:ActionTypes.SetActive,payload: {id:id}}) } const AppWithRedux = ({items,activeItem})=> { const addNewPath = () => { const newItem = new Item('one more path: '+ Math.floor(Math.random()*10000)); ItemActions.addNew(newItem); } const resetList = () => { ItemActions.reset(); } const setMe = async (id)=>{ CurrentStateActions.setActive(id); } ... ...
New we can to some additional stuff, like change different state on the same action:
const ItemActions = { addNew: newItem => { store.dispatch({type:ActionTypes.AddItem, payload:{item:newItem}}); store.dispatch({type:ActionTypes.SetActive,payload: {id:newItem.id}}) }, ... ...
Or add some async tasks:
const ItemActions = { loadNew: async (itemId) => { const loadedItem = await fetch(somePath + itemId); store.dispatch({type:ActionTypes.AddItem, payload:{item:loadedItem}}); store.dispatch({type:ActionTypes.SetActive,payload: {id:loadedItem.id}}) }, ... ...
Conclusion
In the beginning, I really didn’t like Redux, with all its boilerplate code. But once you get into React, and pass the small app stage, you see the benefit of well-structured immutable, expectable testable data, I adopted into my toolkit. I still need to figure out some sections that just don’t smell well enough, but it was fun without the endless grazing FE fields to collect additional information.