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

Move out internal-state from redux store #633

Closed
wants to merge 2 commits into from

Conversation

pradeepnschrodinger
Copy link
Collaborator

Context

Our redux store has a group of state fields known as "internal state".
These are only used within our reducers, but are mutated for performance.
Fields like storedHeights (which stores the height for each row), rowBufferSet, and rowOffsetIntervalTree are part of the internal state.

Starting from FDT v2 onwards, we use reduxjs/toolkit, which in turn uses immer.
With immer, our reducers no longer need to worry about immutability.

Problem

Internally, immer uses proxies on the entire redux store state inorder to detect mutations.
This is slightly inefficient against large data structures (eg: an array containing 1 million elements).

Unfortunately, fields in "internal state", like storedHeights, are pretty big most of the time and are expensive for immer.
There's performance drops when we start modifying internal state every render, which is often the case when we scroll in a table having dynamic row heights.

Solution

We move out internal state out of the final redux store state, and instead expose it through a getter function getInternal().
The getter function has a closure on "internal state", and can easily return it.
This means there's no proxies watching over the actual internal state, and hence mutating it has no overheads.

Additional Changes

I also added a "dynamic row heights" example where a table with 100k rows with varying heights are rendered.
Without the fix, there's performance drops while vertically scrolling across the table.

How Has This Been Tested?

Tested in LD, and our existing examples.

Screenshots (if appropriate):

image

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.

@@ -0,0 +1,102 @@
/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The component here is used for the "Dynamic Row Heights" example.
It renders a table with 100k rows where each row has a different row height.

Without moving internal-state out of the redux store, this example runs slow in FDT v2.

@@ -188,7 +188,7 @@ function calculateRenderedRowRange(state, scrollAnchor) {

// Handle a case where the offset puts the first row fully offscreen
// This can happen if availableHeight & maxAvailableHeight are different
const { storedHeights } = state;
const { storedHeights } = state.getInternal();
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

storedHeights is no longer a direct part of the redux store state. Instead getInternal is used to access it.

The rest of the code changes in the PR are similar: Accessing internal state like storedHeights, rowBufferSet, and rowOffsetIntervalTree are now done through the getter.

@@ -32,6 +41,8 @@ import { createSlice } from '@reduxjs/toolkit';
* @return {!Object}
*/
function getInitialState() {
const internalState = createInternalState();
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We create the internal state here and assign it to the local variable internalState.

The getter defined down below -- getInternal -- accesses internal state via closure.

* TODO (jordan) investigate if we want to move this to local or scoped state
/**
* Internal state is only used by reducers.
* NOTE (jordan, pradeep): Internal state is altered in place, so don't trust it for redux history or immutabability checks.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@wcjordan, pinging you for sanity checking since you have the most context regarding "internal state".

Copy link
Member

Choose a reason for hiding this comment

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

Oh man, it's been too long for me to have much of an opinion here. But no strong concerns from my end...

The only thing I would double check is, my understanding of redux is that it's designed so reducers are pure functions which take state as input and produce the next state as output. With this change, that's no longer true. They take the state + the internal state object as input instead. You may want to think through any assumptions of redux which might be violated by that.

Copy link
Collaborator Author

@pradeepnschrodinger pradeepnschrodinger Jun 6, 2022

Choose a reason for hiding this comment

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

@wcjordan , good point. I've been debating myself over this the last couple of days...

Storing internal state outside the redux store almost makes it seem like it's an external factor to our reducers, which can make the reducers seem impure in a sense.
But I'll also argue that currently internal state values are strictly and purely calculated out of input state, and are not modifiable anywhere else.
So internal state could just be seen as functions over input state.
Technically, this makes our reducers remain pure and deterministic.


The only other issue that I could think of is how internal state is mutated...
This breaks time travel debugging and serialization of state, both of which we haven't been caring about since v1, but I was wondering if anything else breaks?

In our reducers, we assign parts of internal state back to output state (eg: slices of storedHeights are copied over to visible row heights).
From React's/Redux's perspective, output state could be seen as mutated, potentially causing strict equality checks to pass even when data changed.

But so far, this has never been an issue just because the entire subset of data that gets assigned over from internal state to output state is primitive data.

I think we're safe here for now, but we'll need to revisit this once more if our use cases start getting complicated.

Copy link
Member

Choose a reason for hiding this comment

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

Got it. Thanks for your analysis. I appreciate the detail and sounds like we're on a reasonable track with this change.

@pradeepnschrodinger pradeepnschrodinger added the v2.0-beta Tickets targeting the v2.0-beta branch. label May 11, 2022
@pradeepnschrodinger pradeepnschrodinger added this to the v2.0 milestone Jul 6, 2022
pradeepnschrodinger added a commit that referenced this pull request Jul 12, 2022
Internal State is no longer a direct part of the redux store.
It's instead accessed using a getter method `getInternal`.
This is used to speed up code execution by avoiding overheads introduced by immer.

Explanation:
Immer internally uses proxies on the entire redux store state inorder to detect state mutations in reducers,
but watching large data structures is inefficient and slows down reducers.

With the changes in this commit, Internal state is no longer a direct part of the redux store state because we separated it through a getter.
This means there's no proxies watching over the internal state, and hence mutating it has no overheads.

Other changes:
I also added a "Dynamic Row Heights" example.
@pradeepnschrodinger
Copy link
Collaborator Author

Merged manually through 6989002.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
v2.0-beta Tickets targeting the v2.0-beta branch.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants