Initializing Enclave...

How to Fix 'Redux: A Non-Serializable Value Was Detected in the State' (Root Cause + Refactor)

Bug Severity: HIGH | UX/User Impact: SEVERE (broken DevTools, failed state hydration, silent SSR mismatch) | Time to Fix: 10–20 mins

TL;DR

  • What broke: A non-serializable value — a class instance, Date object, Map, Set, Function, or Promise — was written into Redux state, violating the Redux serialization contract.
  • How to fix it: Replace the offending value with a plain serializable equivalent (ISO string instead of Date, plain object instead of class instance, array of tuples instead of Map).
  • Shortcut: Use our Client-Side Sandbox above to paste your reducer or slice — it will auto-detect the non-serializable field and generate the refactored code instantly.

The Incident (What Does the Error Mean?)

The raw warning thrown by @reduxjs/toolkit or redux-immutable-state-invariant middleware:

A non-serializable value was detected in the state, in the path: `someSlice.user.createdAt`.
Value: Wed Jun 11 2025 10:22:00 GMT+0000
Take a look at the reducer(s) handling this action type: user/setProfile.

Immediate consequences:

  • Redux DevTools time-travel breaks silently — replaying actions produces inconsistent state.
  • redux-persist fails to serialize the store to localStorage, corrupting persisted state on reload.
  • SSR hydration mismatches: server-serialized state (JSON) cannot round-trip a Date or class instance, so client rehydration produces a different object reference, triggering React hydration errors.
  • JSON.stringify(store.getState()) either throws or silently drops the field.

The Attack Vector / Blast Radius

This is not a single-component bug. The blast radius scales with how central the offending slice is:

Scenario 1 — Date object in state: Every component subscribed to that slice re-renders with an object that looks correct in the UI but cannot be serialized, persisted, or replayed. QA never catches it because the app renders fine in a fresh session. It detonates the first time a user refreshes with persisted state or a developer tries to replay a bug via DevTools.

Scenario 2 — Class instance (e.g., new UserModel()) in state: Class instances carry prototype methods. After JSON.stringify → JSON.parse (persist, SSR, postMessage to a Web Worker), the prototype chain is stripped. The rehydrated object is a plain object missing all methods. Any call to state.user.getFullName() throws TypeError: state.user.getFullName is not a function — in production, after a page reload, for every user.

Scenario 3 — Map or Set in state: JSON.stringify(new Map([['a', 1]])) produces {}. The entire data structure silently vanishes on persist/rehydrate. If this is a permissions map or feature-flag set, you've just silently dropped authorization data.

Scenario 4 — Function or Promise in state: Functions are not data. Storing a callback or thunk in state is an architectural error that makes the store non-testable and non-serializable by definition.


How to Fix It (The Solution)

Basic Fix — Replace Non-Serializable Types

Date object → ISO string:

// userSlice.js
 const userSlice = createSlice({
   name: 'user',
   initialState: {
-    createdAt: new Date(),
+    createdAt: new Date().toISOString(),
   },
   reducers: {
     setProfile: (state, action) => {
-      state.createdAt = action.payload.createdAt; // payload is a Date object
+      state.createdAt = action.payload.createdAt instanceof Date
+        ? action.payload.createdAt.toISOString()
+        : action.payload.createdAt;
     },
   },
 });

Class instance → plain object:

- import { UserModel } from './models/UserModel';

  setUser: (state, action) => {
-   state.user = new UserModel(action.payload);
+   state.user = {
+     id: action.payload.id,
+     name: action.payload.name,
+     email: action.payload.email,
+   };
  }

Map → plain object or array of tuples:

  initialState: {
-   permissions: new Map([['admin', true], ['editor', false]]),
+   permissions: { admin: true, editor: false },
  }

Enterprise Best Practice — Enforce Serialization at the Middleware Layer

Don't rely on developers remembering. Enforce it in the store configuration.

// store.js
 import { configureStore } from '@reduxjs/toolkit';

 const store = configureStore({
   reducer: rootReducer,
+  middleware: (getDefaultMiddleware) =>
+    getDefaultMiddleware({
+      serializableCheck: {
+        // Whitelist only if you have a documented, justified exception (e.g., redux-form)
+        ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
+        // NEVER ignore entire state paths without a written ADR
+        // ignoredPaths: ['someSlice.dangerousField'], // <-- do not do this blindly
+        warnAfter: 32, // ms threshold before warning fires
+      },
+    }),
 });

If you are using redux-persist, the persist/PERSIST and persist/REHYDRATE actions carry non-serializable transforms internally. Whitelist only those specific actions, not entire state paths.

For class-heavy domains (e.g., you receive API responses as class instances from an ORM or SDK): normalize at the boundary — in the thunk or RTK Query transformResponse — before the data ever touches the store.

// api slice (RTK Query)
  transformResponse: (response) => {
-   return response.data; // response.data is a class instance from the SDK
+   return {
+     id: response.data.id,
+     name: response.data.name,
+     status: response.data.status,
+   };
  }

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. ESLint rule — redux-immutable-state-invariant in test environments: Ensure NODE_ENV=test runs with the full middleware stack so Jest catches serialization violations before they reach a PR.

# .env.test
REDUX_SERIALIZE_CHECK=true

2. Custom ESLint rule or eslint-plugin-redux-saga / manual lint: Ban new Date(), new Map(), new Set() inside reducer files and slice initialState definitions.

// .eslintrc
{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "NewExpression[callee.name='Date']",
        "message": "Do not use 'new Date()' in Redux state. Use Date.now() or new Date().toISOString() and store as a number or string."
      }
    ]
  }
}

3. RTK serializableCheck set to throw in CI: By default the middleware only warns. In your test environment, override it to throw:

// store.test.ts
 const store = configureStore({
   reducer: rootReducer,
+  middleware: (getDefaultMiddleware) =>
+    getDefaultMiddleware({
+      serializableCheck: {
+        isSerializable: (value) => {
+          const result = isPlain(value);
+          if (!result) throw new Error(`Non-serializable value in Redux state: ${typeof value}`);
+          return result;
+        },
+      },
+    }),
 });

This turns silent production warnings into hard CI failures on the branch that introduced them.

4. Architecture Decision Record (ADR): Any ignoredPaths or ignoredActions added to serializableCheck must be accompanied by a committed ADR explaining the exception, the risk accepted, and the owner. This prevents the whitelist from becoming a graveyard of forgotten hacks.

Related Diagnostics

"Part of the Performance Utility Matrix."

View all 219 Performance Tools →