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.

GF Securities FinTech
GF Securities FinTech
GF Securities FinTech
Mastering Redux: Designing Scalable State Structures for React Apps

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:

UI layout example
UI layout example
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.

ReduxState ManagementReActNormalization
GF Securities FinTech
Written by

GF Securities FinTech

Dedicated to sharing the hottest FinTech practices

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.