Angular/NgRx Reducer中嵌套状态结构的更新问题
Hey there! Let's walk through building a solid reducer for your nested time entry state—since we're dealing with arrays of objects inside another array, the key here is sticking to immutable updates (no mutating the original state directly, which is a must for reducers).
First, let's lay out the foundational pieces: action types, action interfaces, then the reducer itself.
Step 1: Define Action Types & Interfaces
Start by enumerating the actions your reducer will handle, then type their payloads to keep things type-safe:
// Action types export enum TimeEntryActionTypes { UPDATE_TIME_ENTRY_HOURS = 'UPDATE_TIME_ENTRY_HOURS', ADD_TIME_ENTRY_ROW = 'ADD_TIME_ENTRY_ROW', ADD_TIME_ENTRY_TO_ROW = 'ADD_TIME_ENTRY_TO_ROW', DELETE_TIME_ENTRY = 'DELETE_TIME_ENTRY' // Optional, for extra functionality } // Action interfaces interface UpdateTimeEntryHoursAction { type: TimeEntryActionTypes.UPDATE_TIME_ENTRY_HOURS; payload: { rowIndex: number; cellIndex: number; hours: number; }; } interface AddTimeEntryRowAction { type: TimeEntryActionTypes.ADD_TIME_ENTRY_ROW; // Optional payload if you need to initialize the row with entries } interface AddTimeEntryToRowAction { type: TimeEntryActionTypes.ADD_TIME_ENTRY_TO_ROW; payload: { rowIndex: number; initialHours?: number; // Optional default hours }; } // Combine all actions into a union type export type TimeEntryAction = | UpdateTimeEntryHoursAction | AddTimeEntryRowAction | AddTimeEntryToRowAction;
Step 2: Build the Reducer with Immutable Updates
The core rule here: never modify the original state or its nested objects/arrays. Instead, create copies of the parts you need to update, leaving the rest untouched.
Here's the reducer handling the key operations:
import { TimeState, TimeEntryRow, TimeEntry } from './your-types-file'; // Import your existing types import { TimeEntryAction, TimeEntryActionTypes } from './your-actions-file'; export const timeEntryReducer = (state: TimeState = { timeEntryRows: [] }, action: TimeEntryAction): TimeState => { switch (action.type) { // Update hours for a specific time entry case TimeEntryActionTypes.UPDATE_TIME_ENTRY_HOURS: { const { rowIndex, cellIndex, hours } = action.payload; // Map over rows to find the target row const updatedRows = state.timeEntryRows?.map(row => { if (row.rowIndex !== rowIndex) return row; // Return unchanged rows // Map over entries in the target row to update the correct cell const updatedEntries = row.timeEntries.map(entry => { if (entry.cellIndex !== cellIndex) return entry; // Return unchanged entries return { ...entry, hours }; // Return new entry with updated hours }); return { ...row, timeEntries: updatedEntries }; // Return new row with updated entries }) ?? []; // Fallback to empty array if timeEntryRows is undefined return { ...state, timeEntryRows: updatedRows }; // Return new state } // Add a new time entry row case TimeEntryActionTypes.ADD_TIME_ENTRY_ROW: { // Calculate the next row index (use max existing index + 1, or 0 if no rows) const nextRowIndex = state.timeEntryRows?.length ? Math.max(...state.timeEntryRows.map(r => r.rowIndex)) + 1 : 0; const newRow: TimeEntryRow = { rowIndex: nextRowIndex, timeEntries: [] // Initialize with empty entries, or add default ones if needed }; // Create new rows array with the added row const updatedRows = [...(state.timeEntryRows ?? []), newRow]; return { ...state, timeEntryRows: updatedRows }; } // Add a new time entry to an existing row case TimeEntryActionTypes.ADD_TIME_ENTRY_TO_ROW: { const { rowIndex, initialHours = 0 } = action.payload; // Find the target row first const targetRow = state.timeEntryRows?.find(row => row.rowIndex === rowIndex); if (!targetRow) return state; // Exit if row doesn't exist // Calculate next cell index for the new entry const nextCellIndex = targetRow.timeEntries.length ? Math.max(...targetRow.timeEntries.map(e => e.cellIndex)) + 1 : 0; const newEntry: TimeEntry = { cellIndex: nextCellIndex, hours: initialHours }; // Update the rows array with the new entry in the target row const updatedRows = state.timeEntryRows?.map(row => { if (row.rowIndex !== rowIndex) return row; return { ...row, timeEntries: [...row.timeEntries, newEntry] }; }) ?? []; return { ...state, timeEntryRows: updatedRows }; } // Default case: return original state for unrecognized actions default: return state; } };
Key Notes for Success
- Immutable Updates: We use the spread operator (
...) to copy objects and arrays instead of modifying them. This ensures redux can detect state changes correctly. - Optional Property Handling: Since
timeEntryRowsis marked as optional, we use the nullish coalescing operator (??) to fall back to empty arrays where needed, avoiding runtime errors. - Index Reliability: This implementation assumes
rowIndexandcellIndexare unique and correctly reflect the position. If your app allows reordering or deleting rows/cells, consider using unique IDs instead of indices to avoid mismatches.
Optional: Simplify with Immer
If manual immutable updates feel tedious, the Immer library lets you write "mutating" code that gets converted to immutable updates under the hood. Here's how the reducer looks with Immer:
import { produce } from 'immer'; import { TimeState } from './your-types-file'; import { TimeEntryAction, TimeEntryActionTypes } from './your-actions-file'; export const timeEntryReducer = produce((draft: TimeState, action: TimeEntryAction) => { switch (action.type) { case TimeEntryActionTypes.UPDATE_TIME_ENTRY_HOURS: { const { rowIndex, cellIndex, hours } = action.payload; const targetRow = draft.timeEntryRows?.find(row => row.rowIndex === rowIndex); if (targetRow) { const targetEntry = targetRow.timeEntries.find(entry => entry.cellIndex === cellIndex); if (targetEntry) targetEntry.hours = hours; } break; } case TimeEntryActionTypes.ADD_TIME_ENTRY_ROW: { const nextRowIndex = draft.timeEntryRows?.length ? Math.max(...draft.timeEntryRows.map(r => r.rowIndex)) + 1 : 0; draft.timeEntryRows?.push({ rowIndex: nextRowIndex, timeEntries: [] }); break; } case TimeEntryActionTypes.ADD_TIME_ENTRY_TO_ROW: { const { rowIndex, initialHours = 0 } = action.payload; const targetRow = draft.timeEntryRows?.find(row => row.rowIndex === rowIndex); if (targetRow) { const nextCellIndex = targetRow.timeEntries.length ? Math.max(...targetRow.timeEntries.map(e => e.cellIndex)) + 1 : 0; targetRow.timeEntries.push({ cellIndex: nextCellIndex, hours: initialHours }); } break; } } }, { timeEntryRows: [] }); // Default state passed as the second argument
Immer cuts down on boilerplate and makes nested updates much more readable—definitely worth considering if you have complex state structures!
内容的提问来源于stack exchange,提问作者ElliotSchmelliot

