Mastering Redux: Designing Scalable State Structures for React Apps
This article explains why Redux is useful for large React applications, categorizes typical state types, and presents practical patterns—including UI‑driven structures, direct API mapping, and normalized entities—along with code samples and loading‑state management techniques to build maintainable front‑end codebases.
Why Use Redux
When building large React applications, state management becomes a challenge; Redux offers a common solution that enables data reuse across components, avoids deep prop‑drilling, simplifies persistence, and provides dev‑tools for time‑travel debugging. However, not every piece of state needs to live in Redux—local UI state (e.g., a dropdown’s open/close flag) can remain in component state.
Common State Types
React state can be grouped into three categories based on source and scope:
Domain data : data fetched from the server such as post lists or comments, often shared across many views.
UI state : flags that control visual presentation (modal visibility, dropdown open state) and usually belong to a single component.
App state : global flags like loading indicators, selected items, or current route information that multiple components may need.
How to Design State Structure
Three common approaches are discussed:
1. Store API response directly
When the backend payload matches the UI layout (e.g., a list page), the response can be placed in state without transformation.
2. Design state around UI layout
For static pages with distinct sections, the state mirrors the UI hierarchy. Example UI diagram (illustrated below) leads to a straightforward state shape:
tabData: {
opening: [{ userId: "6332", mobile: "1858849****", name: "test1", ... }],
missing: [],
commit: [{ userId: "6333", mobile: "1858849****", name: "test2", ... }]
}3. Normalize (state‑normalization)
When data is nested or relational (e.g., groups containing members), flatten it into tables with byId maps and allIds arrays. This reduces duplication, simplifies updates, and improves performance.
const Groups = [
{
id: 'group1',
groupName: '连线电商',
groupMembers: [
{ id: 'user1', name: '张三', dept: '电商部' },
{ id: 'user2', name: '李四', dept: '电商部' }
]
},
{
id: 'group2',
groupName: '连线资管',
groupMembers: [
{ id: 'user1', name: '张三', dept: '电商部' },
{ id: 'user3', name: '王五', dept: '电商部' }
]
}
];Normalized state example:
{
groups: {
byIds: {
group1: { id: 'group1', groupName: '连线电商', groupMembers: ['user1','user2'] },
group2: { id: 'group2', groupName: '连线资管', groupMembers: ['user1','user3'] }
},
allIds: ['group1','group2']
},
members: {
byIds: {
user1: { id: 'user1', name: '张三', dept: '电商部' },
user2: { id: 'user2', name: '李四', dept: '电商部' },
user3: { id: 'user3', name: '王五', dept: '电商部' }
},
allIds: []
}
}Further: Entity‑Based Root State
All relational data can be placed under an entities slice, while UI‑specific data lives in a separate ui slice. This keeps the root state tidy and makes updates target‑specific.
{
simpleDomainData1: { ... },
simpleDomainData2: { ... },
entities: {
groups: { group1: { id: 'group1', groupName: '连线电商' }, ... },
members: { user1: { id: 'user1', name: '张三', dept: '电商部' }, ... }
},
ui: { uiSection1: { ... }, uiSection2: { ... } }
}Action creators and reducers can be generalized to handle any entity type by passing a key (e.g., 'groups' or 'members') along with the payload.
export const addGroup = entity => ({ type: ADD_GROUP, payload: { data: { [entity.id]: entity }, key: 'groups' } });
export const updateGroup = entity => ({ type: UPDATE_GROUP, payload: { data: { [entity.id]: entity }, key: 'groups' } });
// similar for members
function normalAddReducer(state, action) {
const { key, data } = action.payload || {};
return key ? state.set(key, state[key].merge(data)) : state;
}
function normalUpdateReducer(state, action) {
const { key, data } = action.payload || {};
return key ? state.set(key, state[key].merge(data, { deep: true })) : state;
}Extract Loading State to Root Reducer
Instead of scattering loading flags across feature reducers, define a dedicated loading slice in the root reducer and manage it via a generic SET_LOADING action.
const SET_LOADING = 'SET_LOADING';
export const LOADINGMAP = { groupsLoading: 'groupsLoading', memberLoading: 'memberLoading' };
const initialLoadingState = Immutable({
[LOADINGMAP.groupsLoading]: false,
[LOADINGMAP.memberLoading]: false
});
const loadingReducer = (state = initialLoadingState, action) => {
const { type, payload } = action;
return type === SET_LOADING ? state.set(payload.key, payload.loading) : state;
};
export const setLoading = (scope, loading) => ({
type: SET_LOADING,
payload: { key: scope, loading }
});
// usage: store.dispatch(setLoading(LOADINGMAP.groupsLoading, true));This pattern centralizes loading logic and can be extended easily; frameworks like dva adopt a similar plugin‑based approach.
Other Considerations
When a user lands directly on a page, the store may not have the required data, leading to errors. Some teams split the store per page, sacrificing cross‑page state sharing. Before sharing state, ask:
How many pages need this data?
Does each page need its own copy?
How frequently does the data change?
These questions help decide the appropriate granularity of state.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
