Compatibility

Does Obfuscation Break Your State Management Store?

A reducer-based store looks like it should be fragile under renaming. It is built almost entirely out of property names: actions carry a type and a payload, the reducer switches on one and reads the other, selectors reach into a state tree by key, and the whole thing is usually persisted somewhere between sessions. It turns out that most of that is safe for a reason worth understanding, and the parts that are not are not the parts people expect.

What was measured

The sample is a small store written without a library, so that every moving part is visible in the file: three action creators, a reducer with a switch over action types and a default branch that returns the state unchanged, a store with dispatch and getState and a log of dispatched types, a selector that filters the state, a JSON serialisation of the state, a rehydration from a JSON string written by a previous build, and a handler map keyed by the action type strings.

It runs four dispatches, then prints the revision counter, the item count, the first item, the current filter, the dispatch log, the selector result, the serialised wire format, the keys in that wire format, and the results of rehydrating and reducing over restored state. Twelve assertions in all.

In the base configuration all twelve were identical to the original on all five profiles: the default output target, the modern output target, both gate profiles and the string-transform profile. As with most surfaces in this series, the interesting results only appear once member renaming is switched on.

Most of the store is safe, and the reason generalises

The first result contradicted what I expected to find, which makes it worth stating first. A member pattern matching type -- the discriminant the entire reducer switches on -- changed nothing. Every assertion held.

The reason is that the switch does not compare property names. It compares the string 'todo/add' against the value stored under whatever that property is now called. The action creator writes the property and the reducer reads it, both in your code, both renamed together, and the string values that actually distinguish one action from another are string literals, which are never rename sites. Renaming payload behaved the same way, as did renaming the store's own dispatch and getState.

This is the general rule this site keeps arriving at from different directions: renaming is a consistent substitution, so it is safe wherever both halves of a contract are inside the build, and unsafe wherever one half is fixed somewhere the obfuscator cannot rewrite. A reducer is almost entirely the first kind. Action type strings, the switch, the selectors and the store API are all self-contained.

It is also a useful corrective to a common instinct, which is to protect the store by quoting things or by anchoring patterns away from words like type. On this evidence that effort is aimed at the wrong risk.

The state that leaves your process is the real exposure

Persisted state is where a store stops being self-contained. The moment the state tree is written to storage, or sent to a server, or read back from a blob that a previous build wrote, its key names become a contract with something outside the build.

Renaming the two field names on the items in the sample changed the serialised output from {"text":"write tests","done":true} to {"_0x1":"write tests","_0x2":true}. Within a single run that is harmless, because everything that reads it was renamed too. Across builds it is not. The sample rehydrates from a JSON string standing in for state persisted by an earlier version, and after renaming the restored item's text read undefined while the item count stayed correct at two. Nothing was thrown. A user's saved session comes back structurally intact and empty of content.

That is the failure to plan for, and it has a property that makes it particularly awkward: it appears on upgrade rather than on deploy. A build where the persisted keys and the reading code were renamed in the same pass is perfectly consistent with itself. It only breaks when one build writes and a differently-named build reads, which is exactly what happens to returning users after a release.

If you persist state, the fix is to stop persisting the live object shape. Serialise through an explicit mapping to fixed key names, or version the blob and migrate it, or keep member patterns away from the field names that cross that boundary. Any of the three works; the important part is that the decision is made deliberately rather than inherited from whatever your pattern happened to match.

One ordinary field name that reaches a built-in

The sharpest failure in this sample came from the most innocuous-looking pattern in it. Matching the three top-level state keys -- todos, filter and revision -- threw TypeError: state._0x2._0x1 is not a function before the store finished running.

The cause is that filter is both a perfectly ordinary name for a piece of UI state and the name of a built-in array method. The selector calls state.todos.filter(...), member renaming is name-based rather than type-aware, and so the pattern intended to describe the state tree also renamed the array method call in the same expression. The error names two generated identifiers and mentions neither the state key nor the method, which makes it read like a structural bug in the store.

It is worth dwelling on how ordinary that pattern is. A todo list with a filter field is the canonical example in essentially every state management tutorial ever written, so this is not a contrived collision. The same hazard covers map, find, keys, values, entries, sort and reduce, every one of which is both a plausible state field and a real method.

The mitigation is the same one that keeps appearing: write member patterns that are anchored to something distinctive rather than to a list of bare common words. A prefix on your own state fields, or a pattern that matches a naming convention rather than an enumeration, removes this entire class of collision at once.

Debugging tools see the renamed shape, not yours

A reducer store is usually paired with a devtools panel, a time-travel debugger or an action log, and all three read the same objects your reducer does. None of them can rename anything back.

The dispatch log in the sample records action types as they are dispatched, and it came through renaming unchanged -- todo/add,todo/add,todo/toggle,filter/set -- for the same reason the switch survives: those are string values. So an action log remains readable after protection, which is more than can be said for most of what a debugger shows you, and it is the single most useful thing to log if you are diagnosing a protected build.

The state tree next to it is a different story. Anything that inspects state generically -- printing keys, diffing two snapshots, rendering a tree view -- shows whatever the properties are now called. The sample's serialised state is the concrete example: the keys read todos,filter,revision before and generated names after. A diff between two protected snapshots is still correct and still useful, because both sides moved together; a diff between a protected snapshot and a recorded expectation from an unprotected build is not.

There is a practical consequence for bug reports. If you ask users to export state from a protected build, you receive an object whose field names do not appear anywhere in your source. Either record a build identifier alongside the export so you can map it back, or export through the same explicit mapping used for persistence, which solves both problems with one piece of code.

What to check on your own build

Three checks, in decreasing order of how likely they are to matter.

First, the upgrade path. Take a state blob written by your current production build, load it into a protected build of the new version, and print one field from deep inside it. This is the only check that catches the persisted-state problem, and it cannot be caught by testing a single build against itself.

Second, the built-in collision. Protect, then run whatever exercises your selectors. This one fails loudly and immediately, so any smoke test that touches the store will find it -- the difficulty is reading the error, not triggering it. If you see a TypeError naming two generated identifiers where your source calls an array method, this is what happened.

Third, and only if you are curious rather than worried, the self-contained parts. Actions, reducers, selectors and the store API measured clean under renaming, so a passing test here confirms the general rule rather than protecting you from a specific risk.

Frequently asked questions

Does obfuscation break Redux-style reducers?

Not in the base configuration, where all twelve assertions in the sample were identical to the original on all five profiles measured. Under member renaming the reducer itself also held up: matching the action type discriminant, the payload, and the store's dispatch and getState all changed nothing, because both halves of each of those contracts live in your own code and are renamed together.

Is it safe to rename a property called type?

In a reducer, yes, on the evidence measured. The switch compares string literals such as 'todo/add' against the value stored under that property, and string literals are never rename sites. The action creator writes the property and the reducer reads it, both inside the build, so they move together. This was the result that most contradicted expectation.

Why is my persisted state empty after upgrading to a protected build?

Because the key names in the stored blob were written by a build that named them differently. Renaming the item fields changed the serialised output to generated names, and rehydrating a blob written before that change produced a structurally correct object whose fields all read undefined -- the item count stayed right while the content did not. Nothing is thrown. Serialise through an explicit mapping to fixed key names, or version and migrate the blob.

Why do I get a TypeError naming generated identifiers in my selector?

Most likely a member pattern that matches a state field which is also a built-in array method. Matching a state tree containing a field called filter renamed the array method in state.todos.filter(...) as well, giving TypeError: state._0x2._0x1 is not a function. The names map, find, keys, values, entries, sort and reduce carry the same hazard.

Does member renaming change my action type strings?

No. Action types are string values rather than property names, and string literals are not rename sites. The dispatch log in the sample recorded the same four type strings before and after renaming, and a handler map keyed by those strings continued to resolve correctly.

Do I need to change my store before enabling obfuscation?

Only if the state leaves your process. A store that is created, mutated and read entirely within one build measured clean under renaming across actions, reducers, selectors and the store API. The work is in the persistence boundary and in keeping member patterns off names that collide with built-in methods.

What is the one check that a single build cannot perform?

The upgrade path. A build whose persisted keys and reading code were renamed in the same pass is entirely self-consistent, so testing it against its own output proves nothing about a returning user. Load a state blob written by your current production build into the new protected build and print a field from inside it.

Related reading