Advanced Web Programming
Redux, Application Structure and End-to-end Integration
PGCP-AC
A complete web feature crosses several boundaries: component state, shared application state, routing, HTTP, server validation, business rules, persistence and user feedback. Redux makes selected client transitions explicit, but good integration still depends on placing each responsibility in the correct layer.
1. Predictable state transitions
Redux organizes shared state around a store, actions and reducers. An action describes an event, usually with a type and associated data. Dispatch sends an action to the store. A reducer computes the next state from the previous state and the action. Reducers should be deterministic and free from side effects such as network requests. They must preserve earlier state instead of directly mutating ordinary state objects.
A selector reads or derives a value from state. React-Redux connects components to a store through a Provider and hooks such as useSelector and useDispatch. Component-local state is still appropriate for a value used by only one component. Shared application state belongs in a store when coordinated access and explicit transitions justify it. Middleware can coordinate asynchronous work and dispatch actions describing request progress and results.
2. A complete request path
Consider adding a book. A form holds input state, validates obvious omissions and sends JSON to an Express endpoint. The server authenticates the requester, validates the data, invokes business logic, persists the record and returns a status and representation. The client then updates its displayed data. Every stage has a separate responsibility; accepting data in the browser does not establish that it was stored successfully.
function counter(state = 0, action) {
switch (action.type) {
case 'increment': return state + 1;
default: return state;
}
}
Track loading, success and failure distinctly. Protect secrets on the server, validate authorization for each protected operation and avoid displaying untrusted markup as executable content. Tests can check reducers as pure functions, API handlers with controlled dependencies and user flows through observable behavior. A responsive page, a correct state transition and a successful database write together form the finished feature.
3. A reducer with a structured state
function cart(state = { items: [] }, action) {
if (action.type === 'cart/added') {
return { ...state, items: [...state.items, action.payload] };
}
return state;
}
Both the outer state object and the changed array receive new identities. The unchanged items inside the array need not be cloned unless they are themselves being changed. A selector can calculate the total from quantity and price instead of storing a second total that every action must remember to maintain.
The request lifecycle can be represented by pending, fulfilled and rejected events. A reducer records their effects; middleware performs the external interaction. The server response remains the authority for generated identifiers and accepted values. If an optimistic UI displays a change before confirmation, it needs a defined rollback or reconciliation path when the request fails.
4. Redux data flow
Redux follows one-directional flow:
- UI or middleware dispatches an action.
- The store calls the root reducer with current state and action.
- Reducers calculate the next state.
- The store publishes the new state.
- Subscribed UI selects values and rerenders where selected results changed.
An action describes what happened rather than commanding a particular mutation:
{ type: 'cart/itemAdded', payload: { productId: 'p17', quantity: 1 } }
Action types use stable names and payloads carry the minimum relevant event data. Dispatch is the store entry point. Components do not call reducers directly or modify store state.
5. Reducer requirements
A reducer receives previous state and an action and returns next state. It must be deterministic for those inputs and free of side effects.
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case 'counter/incremented':
return { ...state, value: state.value + 1 };
default:
return state;
}
}
Reducers must not perform HTTP requests, generate uncontrolled random values, read time implicitly, modify arguments or invoke UI code. Unknown actions return existing state. New references are required along changed paths; unchanged branches can retain identity.
6. Redux Toolkit
Redux Toolkit is the standard practical approach for modern Redux setup. configureStore combines reducers and supplies useful defaults. createSlice generates action creators and a reducer from named case reducers.
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
itemAdded(state, action) {
state.items.push(action.payload);
},
itemRemoved(state, action) {
state.items = state.items.filter(item => item.id !== action.payload);
}
}
});
This mutation-like syntax is safe only because Toolkit uses Immer to produce immutable state from the recorded changes. Do not assume ordinary reducers outside that mechanism may mutate state.
const store = configureStore({ reducer: { cart: cartSlice.reducer } });
7. React-Redux integration
Provider makes the store available through React context. useSelector subscribes and derives a selected value; useDispatch returns the store's dispatch function.
function CartCount() {
const count = useSelector(state => state.cart.items.length);
return <output aria-label="Cart items">{count}</output>;
}
A selector runs when relevant store processing occurs. Returning a fresh object every time can cause avoidable rerenders because its identity always differs. Select primitives, use several selectors or memoize genuinely derived results when necessary.
Components should dispatch domain events rather than knowing reducer implementation details. Wrap typed or domain-specific dispatch and selector hooks where project conventions benefit.
8. Selecting the correct state owner
Redux is useful for state shared across distant features, coordinated through explicit events, inspected with development tools or updated by complex asynchronous workflows. It is unnecessary for every value.
| State | Likely owner |
|---|---|
| Text being typed in one form | Component state |
| Theme for one subtree | Context or owner state |
| Authenticated user used across the app | Shared store or focused context |
| Server query cache | Data-fetching/cache layer |
| Temporary open/closed menu | Local component state |
Putting rapidly changing local form text into a global store adds dispatches and coupling without creating useful coordination. Conversely, duplicating authenticated identity in many component states creates inconsistency.
9. State shape and normalization
Store domain records by identity when many operations update or select individual records:
{
courses: {
ids: ['c1', 'c2'],
entities: {
c1: { id: 'c1', title: 'AWP' },
c2: { id: 'c2', title: 'DBT' }
}
}
}
Normalization avoids deeply nested duplicate copies. Relationships can be stored as IDs. Derived arrays and totals belong in selectors rather than additional synchronized state. Toolkit entity adapters can standardize common normalized operations.
Do not normalize tiny data reflexively. Choose a shape that makes updates, reads and invariants clear.
10. Selectors and derived data
A selector receives store state and returns a value used by the application.
const selectCartItems = state => state.cart.items;
const selectCartTotal = state =>
selectCartItems(state).reduce((sum, item) => sum + item.price * item.quantity, 0);
The total is derived and should not be separately updated by every cart action. Memoized selectors are helpful when derivation is expensive or result identity matters. Memoization is an optimization; selector correctness comes first.
11. Middleware and asynchronous workflows
Reducers remain synchronous and pure. Middleware observes dispatch and can coordinate logging, requests, persistence or other external behavior. A common request lifecycle has pending, fulfilled and rejected actions.
const fetchCourses = createAsyncThunk('courses/fetchAll', async (_, api) => {
const response = await fetch('/api/courses', { signal: api.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
The slice records status and results for the generated lifecycle actions. Cancellation, duplicate requests, stale results, retry policy and validation remain design decisions. For server data, a query/cache abstraction may remove much manual lifecycle code.
12. Async state modeling
Do not use a single boolean when the interface needs several outcomes. A practical state may contain:
{
status: 'idle',
entities: [],
error: null,
currentRequestId: null
}
Pending sets loading and records request identity. Fulfilled accepts data only for the relevant request. Rejected records a safe error and clears pending state. Keeping previous data during refresh may provide a better interface than replacing everything with a spinner.
An optimistic update changes local state before server confirmation. It needs an operation identifier and explicit rollback or reconciliation. Never report confirmed persistence merely because the optimistic rendering succeeded.
13. Routing and feature structure
Organize by feature responsibilities rather than placing every component, reducer, API function and test in unrelated global folders.
src/
app/store.js
features/courses/
coursesSlice.js
coursesApi.js
CourseList.jsx
CourseForm.jsx
selectors.js
Routes map URLs to page-level components. Route parameters are strings and must be validated before API use. Navigation after a successful creation should use the server-generated identifier. Protected-route UI improves navigation, but the server still enforces authorization.
14. End-to-end create flow
Consider creating a course:
- A controlled form owns incomplete editable values locally.
- Client validation catches obvious missing or malformed fields.
- Submission dispatches an async operation and shows pending state.
- The client sends JSON with appropriate authentication and content type.
- Express parses with a size limit and validates the representation.
- Authentication establishes identity; authorization checks permission.
- A service applies business rules.
- A repository persists the record transactionally where required.
- The server returns
201 Created, aLocationand the accepted record. - Client state adopts the server ID and normalized values.
- The interface reports success and navigates or resets appropriately.
Every stage can fail differently. Browser validation cannot prove authorization, uniqueness or persistence. The server response is authoritative for accepted and generated values.
15. Authentication and client state
Client state may hold current identity and authorization hints for rendering, but it is not a security boundary. Users can inspect and modify browser state. Every protected endpoint authenticates the request and authorizes the operation.
Avoid placing secrets or database credentials in the client bundle, Redux state, local storage or HTML. Cookie-based and bearer-token systems have different CSRF, XSS, storage, refresh and expiration concerns. Choose a documented design and handle unauthenticated responses consistently.
16. Error contracts and recovery
The server should return stable, safe error information. The client maps expected validation errors to fields, authentication failures to reauthentication, conflicts to correction or refresh and unexpected failures to a retryable general message.
Redux state should not store raw Error instances when serializable state is a project requirement. Keep useful fields such as category, status and safe message. Log diagnostic context at the appropriate boundary without exposing secrets to users.
17. Testing strategy
Test at the narrowest boundary that proves the behavior:
- reducer tests supply state and actions and assert next state;
- selector tests supply state and assert derived values;
- component tests interact as a user and observe accessible output;
- API tests send HTTP requests and assert status, headers, body and persistence effects;
- end-to-end tests cover a small number of critical cross-system journeys.
Avoid tests that merely repeat implementation structure. A reducer's purity makes it easy to test, but an integration test is still required to prove routing, validation, persistence and UI coordination.
18. Configuration and deployment
Client build-time configuration becomes visible in the delivered bundle and must contain no secrets. Server configuration can read protected deployment environment values. Validate required settings at startup.
Production deployment must align API origins, CORS, proxy trust, HTTPS, cookies, static asset paths, database migrations, logs and health behavior. A successful local UI does not prove that the deployed server can connect to its database or that a reverse proxy forwards security information correctly.
19. Practical considerations
- The store holds state; actions describe events; dispatch sends actions.
- Reducers receive previous state and action and return next state.
- Network work does not belong in an ordinary reducer.
- Toolkit mutation syntax depends on Immer-backed case reducers.
- Selectors read or derive data and should avoid unnecessary new identities.
- Not every local value belongs in Redux.
- Middleware coordinates external asynchronous work.
- The server authorizes protected persistence operations.
- Optimistic display is not confirmed persistence.
- Client bundles and state cannot contain secrets.
Continue learning
Related notes
Put this topic into timed practice
Open mock tests when you want full-exam pacing, or keep drilling in practice mode.