Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update createReducer to accept a lazy state init function #1662

Merged
merged 4 commits into from
Oct 29, 2021

Conversation

markerikson
Copy link
Collaborator

@markerikson markerikson commented Oct 28, 2021

This PR:

  • Updates createReducer to accept a lazy state initialization function that won't be called until the reducer is called with undefined
  • Attaches a .getInitialState function to the generated reducer, which will either return the original state value or call the lazy state init function
  • Updates createSlice to accept the same type signature
  • Updates createSlice to expose the reducer's .getInitialState method as part of the slice object

Fixes #1024 .

(Thanks to @JoshuaKGoldberg for the assist with the "is this a function?" check!)

@github-actions
Copy link

github-actions bot commented Oct 28, 2021

size-limit report 📦

Path Size
1. entry point: @reduxjs/toolkit (cjs.production.min.js) 12.32 KB (+0.36% 🔺)
1. entry point: @reduxjs/toolkit (esm.js) 10.31 KB (+0.43% 🔺)
1. entry point: @reduxjs/toolkit/query (cjs.production.min.js) 21.88 KB (+0.22% 🔺)
1. entry point: @reduxjs/toolkit/query (esm.js) 18.22 KB (+0.3% 🔺)
1. entry point: @reduxjs/toolkit/query/react (cjs.production.min.js) 24 KB (+0.19% 🔺)
1. entry point: @reduxjs/toolkit/query/react (esm.js) 20.9 KB (+0.25% 🔺)
2. entry point: @reduxjs/toolkit (without dependencies) (cjs.production.min.js) 5.65 KB (+0.91% 🔺)
2. entry point: @reduxjs/toolkit (without dependencies) (esm.js) 5.62 KB (+0.7% 🔺)
2. entry point: @reduxjs/toolkit/query (without dependencies) (cjs.production.min.js) 9.92 KB (0%)
2. entry point: @reduxjs/toolkit/query (without dependencies) (esm.js) 10.35 KB (0%)
2. entry point: @reduxjs/toolkit/query/react (without dependencies) (cjs.production.min.js) 2.65 KB (0%)
2. entry point: @reduxjs/toolkit/query/react (without dependencies) (esm.js) 2.49 KB (0%)
3. createSlice (esm.js) 5.21 KB (+0.97% 🔺)
3. createEntityAdapter (esm.js) 5.83 KB (0%)
3. configureStore (esm.js) 5.83 KB (0%)
3. createApi (esm.js) 16.46 KB (+0.28% 🔺)
3. createApi (react) (esm.js) 19.09 KB (+0.24% 🔺)
3. fetchBaseQuery (esm.js) 11.09 KB (+0.5% 🔺)
3. setupListeners (esm.js) 9.91 KB (+0.55% 🔺)
3. ApiProvider (esm.js) 17.93 KB (+0.25% 🔺)

@codesandbox-ci
Copy link

codesandbox-ci bot commented Oct 28, 2021

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit eaca9de:

Sandbox Source
Vanilla Configuration
Vanilla Typescript Configuration
rsk-github-issues-example Configuration
@examples-query-react/basic Configuration
@examples-query-react/advanced Configuration

@@ -98,8 +98,10 @@ describe('createReducer', () => {
test('Freezes initial state', () => {
const initialState = [{ text: 'Buy milk' }]
const todosReducer = createReducer(initialState, {})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this now shows a valid concern with the lazyness. Imagine this situation:

const initialState = { foo: "bar" }

const reducer = createReducer(initialState, {})

// foo: "bar"
console.log(reducer(undefined, { type: "init"}))
console.log(reducer.getInitialState())

initialState.foo = "baz"

// foo: "baz" - should not happen
console.log(reducer(undefined, { type: "init"}))
console.log(reducer.getInitialState())

so with a non-function being passed in, we still need to create a frozen copy of the item inside createReducer - but without freezing the outside initialState variable as was the behaviour before. That seems kinda dangerous, too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I agree there's some change in semantics here. On the other hand... the idea of the original state being mutated between the creation of the slice and the store being initialized seems almost nonexistent.

Currently, what would happen is the initial state object gets frozen synchronously during the initial module import processing sequence, before any of the code in store.js would have a chance to run.

If we switch to this, you'd have:

  • Initial object created
  • createReducer captures that value for later
  • Slice module finishes executing, as do all other store dependency modules
  • body of store.js now executes
  • configureStore runs, dispatches '@@init', combineReducers does its things, and now we freeze and return the initial state.

So we're talking the same synchronous execution sequence, just moving the freezing to be somewhat later

I suppose there's other possible edge cases, like using createSlice with useReducer, where there's more of a lag time between the time the reducer captures the initial value and when it's actually used. But even then I see mutation of the initial state as a relatively rare concern.

Copy link
Member

@phryneas phryneas Oct 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could for example play a role in tests or situations where people accidentally create a new store on every render and just keep modifying initialState.
There are many questions on StackOverflow that have "how to change initialState" in the title, which shows that many people really have the concept of "changing initial state outside the reducer" in mind - so I'd like to be as bullet-proof as possible here, even if it means shipping a few extra bytes.

@phryneas phryneas added this to the 1.7 milestone Oct 28, 2021
Comment on lines 215 to 219
const getInitialState = () =>
createNextState(
isStateFunction(initialState) ? initialState() : initialState,
() => {}
)
Copy link
Member

@phryneas phryneas Oct 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be my suggestion to be a bit more bullet-proof there

Suggested change
const getInitialState = () =>
createNextState(
isStateFunction(initialState) ? initialState() : initialState,
() => {}
)
let getInitialState: () => S
if (isStateFunction(initialState)) {
getInitialState = initialState
} else {
const frozenInitialState = createNextState(initialState, () => {})
getInitialState = () => frozenInitialState
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically that doesn't prevent a user-provided initialState() function from returning a non-frozen value / mutable, I think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, the call to getInitialState would not be frozen then. Yeah, that needs another createNextState call then

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, we'd have to defer the createNextState call until usage time, in which case the external state could still get mutated in the meantime?

Still feels like we're trying to solve a problem that's outside our scope here, tbh.

Here's what I've got atm:

  let getInitialState: () => S
  if (isStateFunction(initialState)) {
    getInitialState = () => createNextState(initialState(), () => {})
  } else {
    const frozenInitialState = createNextState(initialState, () => {})
    getInitialState = () => frozenInitialState
  }

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone willingly provides a "lazy initializer" function, I guess they expect that it returns the value at the time of call, not something from before, so I'd say that now it looks like exactly the expected behaviour.

@markerikson markerikson merged commit 0a16fb5 into v1.7.0-integration Oct 29, 2021
@markerikson markerikson deleted the feature/state-lazy-init branch October 29, 2021 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Status: Done
Development

Successfully merging this pull request may close these issues.

2 participants