You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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).

Reducer for Nested Time Entry State

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 timeEntryRows is 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 rowIndex and cellIndex are 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.22 08:41:51