TL;DR
Without combineReducers()
or similar manual code, initialState
always wins over state = ...
in the reducer because the state
passed to the reducer is initialState
and is not undefined
, so the ES6 argument syntax doesn't get applied in this case.
With combineReducers()
the behavior is more nuanced. Those reducers whose state is specified in initialState
will receive that state
. Other reducers will receive undefined
and because of that will fall back to the state = ...
default argument they specify.
In general, initialState
wins over the state specified by the reducer. This lets reducers specify initial data that makes sense to them as default arguments, but also allows loading existing data (fully or partially) when you're hydrating the store from some persistent storage or the server.
First let's consider a case where you have a single reducer.
Say you don't use combineReducers()
.
Then your reducer might look like this:
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT': return state + 1;
case 'DECREMENT': return state - 1;
default: return state;
}
}
Now let's say you create a store with it.
import { createStore } from 'redux';
let store = createStore(counter);
console.log(store.getState()); // 0
The initial state is zero. Why? Because the second argument to createStore
was undefined
. This is the state
passed to your reducer the first time. When Redux initializes it dispatches a “dummy” action to fill the state. So your counter
reducer was called with state
equal to undefined
. This is exactly the case that “activates” the default argument. Therefore, state
is now 0
as per the default state
value (state = 0
). This state (0
) will be returned.
Let's consider a different scenario:
import { createStore } from 'redux';
let store = createStore(counter, 42);
console.log(store.getState()); // 42
Why is it 42
, and not 0
, this time? Because createStore
was called with 42
as the second argument. This argument becomes the state
passed to your reducer along with the dummy action. This time, state
is not undefined (it's 42
!), so ES6 default argument syntax has no effect. The state
is 42
, and 42
is returned from the reducer.
Now let's consider a case where you use combineReducers()
.
You have two reducers:
function a(state = 'lol', action) {
return state;
}
function b(state = 'wat', action) {
return state;
}
The reducer generated by combineReducers({ a, b })
looks like this:
// const combined = combineReducers({ a, b })
function combined(state = {}, action) {
return {
a: a(state.a, action),
b: b(state.b, action)
};
}
If we call createStore
without the initialState
, it's going to initialize the state
to {}
. Therefore, state.a
and state.b
will be undefined
by the time it calls a
and b
reducers. Both a
and b
reducers will receive undefined
as their state
arguments, and if they specify default state
values, those will be returned. This is how the combined reducer returns a { a: 'lol', b: 'wat' }
state object on the first invocation.
import { createStore } from 'redux';
let store = createStore(combined);
console.log(store.getState()); // { a: 'lol', b: 'wat' }
Let's consider a different scenario:
import { createStore } from 'redux';
let store = createStore(combined, { a: 'horse' });
console.log(store.getState()); // { a: 'horse', b: 'wat' }
Now I specified the initialState
as the argument to createStore()
. The state returned from the combined reducer combines the initial state I specified for the a
reducer with the 'wat'
default argument specified that b
reducer chose itself.
Let's recall what the combined reducer does:
// const combined = combineReducers({ a, b })
function combined(state = {}, action) {
return {
a: a(state.a, action),
b: b(state.b, action)
};
}
In this case, state
was specified so it didn't fall back to {}
. It was an object with a
field equal to 'horse'
, but without the b
field. This is why the a
reducer received 'horse'
as its state
and gladly returned it, but the b
reducer received undefined
as its state
and thus returned its idea of the default state
(in our example, 'wat'
). This is how we get { a: 'horse', b: 'wat' }
in return.
To sum this up, if you stick to Redux conventions and return the initial state from reducers when they're called with undefined
as the state
argument (the easiest way to implement this is to specify the state
ES6 default argument value), you're going to have a nice useful behavior for combined reducers. They will prefer the corresponding value in the initialState
object you pass to the createStore()
function, but if you didn't pass any, or if the corresponding field is not set, the default state
argument specified by the reducer is chosen instead. This approach works well because it provides both initialization and hydration of existing data, but lets individual reducers reset their state if their data was not preserved. Of course you can apply this pattern recursively, as you can use combineReducers()
on many levels, or even compose reducers manually by calling reducers and giving them the relevant part of the state tree.