Skip to content

Instantly share code, notes, and snippets.

@KidkArolis
Last active March 16, 2020 13:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save KidkArolis/26cb3f998899b525dbdfd85f7f3c7978 to your computer and use it in GitHub Desktop.
Save KidkArolis/26cb3f998899b525dbdfd85f7f3c7978 to your computer and use it in GitHub Desktop.
Feedback on `useMutableSource`

Dear reader,

In the last few years, I've been using my own global store implementation with great success (yes, I'm aware there exist a bazillion "redux like" stores). My one's called tiny-atom.

In particular, the following 4 requirements for a global store emerged from my needs:

1. Batch multiple, rapid store changes

For example, tiny-atom uses requestAnimationFrame to delay/batch the re-renders.

2. Do not re-render children twice on each store change

If naively implemented – a child component re-renders once from parent re-render, once from it's own subscription.

3. Re-render parent first to avoid "zombie children"

For example, a child component can break reading something non existant from store, when parent was supposed to un-render that child.

4. Only re-render if selected slice of state changed

Important for performance, when many components bind to small slices of the shared state.

My current implementation is not Concurrent Mode ready. So, excitedly, I've tried out the new useMutableSource in react@0.0.0-experimental-8b155d261 in place of my current approach. It works pretty great! The useMutableSource hook takes care of requirements 1-3 out of the box only leaving requirement 4 to the implementer!

Right now, I'm looking for the following answers:

Question 1

How to correctly prevent re-rendering after store change if new snapshot is shallowly equal?

You can see my current approach below, but I don't think it's correct wrt Concurrent Mode.

UPDATE: I have updated the gist and code sandbox with a correct (?) implementation of selective subscription now. No more mutating refs.

Question 2

Is this type of implementation fully Concurrent Mode compatible?

I tried running this simple store implementation through the https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode test suite and the following checks fail:

✕ check 7: proper branching with transition
✕ check 9: no tearing with auto increment

My concern is - does that mean useMutableSource still has some tearing issues in some cases? (Note: I haven't looked into the the implementation of the test suite closely).

UPDATE Anyone know how to convert this super simple store into a fully React compliant store? E.g. I don't mind if it's turned into a useState/useReducer as long as it can be used in a similar manner?

Code

I've put the code used in my exercise here: https://codesandbox.io/s/flamboyant-keller-m9unj

  • App.js - a demo app for exploring the 4 requirements above
  • createStoreModern.js - simpler store used in useMutableSource implementaion
  • useStoreModern.js - implementation of store hooks using useMutableSource
  • createStoreLegacy.js - more complex store that allows order specified subscriptions
  • useStoreLegacy.js - implementation of hooks without useMutableSource

I'm also including the code for the modern implementation directly below

export function createStore(state, actions) {
let listeners = []
function get() {
return state
}
function set(next) {
state = { ...state, ...next }
listeners.forEach(l => l())
}
function dispatch(type, payload) {
actions[type]({ get, set }, payload)
}
function subscribe(f) {
listeners.push(f)
return () => {
listeners = listeners.filter(ff => f !== ff)
}
}
return { get, set, dispatch, subscribe }
}
/*
This is a "modern" version of the store hooks, that could be used in the next version of tiny-atom
once useMutableSource lands in React. It handles our 4 requirements with ease:
1. Re-render at most once per request animation frame
this version does not render once per animation frame, but useMutableSource does batch changes within the same tick
2. Do not re-render children twice (if naively implemented – it happens once from parent, once from subscription)
useMutableSource takes care of that
3. Re-render parents first to avoid "zombie children"
useMutableSource takes care of that
4. Only re-render if selected slice of state changed
this is something we have to handle and I'm not sure about the correct way to do that
*/
import React, { useState, createContext, createMutableSource, useContext, useCallback, useMutableSource } from 'react'
import { createStore } from './createStore'
import { areEqual } from './areEqual'
const MutableSourceContext = createContext()
const MutableSourceProvider = MutableSourceContext.Provider
export function useStore(initialState, actions, deps = []) {
const [store] = useState(() => createStore(initialState, actions))
const [mutableSource] = useState(() => createMutableSource(store, () => store.get()))
const [Provider] = useState(() => ({ children }) => (
<MutableSourceProvider value={mutableSource}>{children}</MutableSourceProvider>
))
return { Provider }
}
export function useSelector(selectorFn = x => x, deps = [], name = 'Unknown') {
const mutableSource = useContext(MutableSourceContext)
const selector = useCallback(selectorFn, deps)
const getSnapshot = useCallback(
memoize(store => selector(store.get())),
[selector]
)
const subscribe = useCallback(
(store, callback) => {
let prevSnapshot
return store.subscribe(() => {
const nextSnapshot = getSnapshot(store)
if (prevSnapshot !== nextSnapshot) {
prevSnapshot = nextSnapshot
callback()
}
})
},
[getSnapshot]
)
return useMutableSource(mutableSource, getSnapshot, subscribe)
}
export function useDispatch() {
const { dispatch } = useStoreInstance()
return dispatch
}
export function useStoreInstance() {
return useContext(MutableSourceContext)._source
}
function memoize(fn) {
let prev
return (...args) => {
const next = fn(...args)
if (!areEqual(prev, next)) {
prev = next
}
return prev
}
}
@KidkArolis
Copy link
Author

Thanks for further clarifications. I've now updated the code above to not use a mutated useRef and instead do a proper scoped subscription.

Now I'm just wondering if it's possible to turn this store into a startTransition compatible version. It's not clear to me yet if its a limitation of useMutableSource or if it rather depends on how the underlying state is stored/evolved.

In other words - what I'm after is a global context (for storing things like user object, fetched data, routing info, etc.) that can be selectively subscribed to (like use-context-selector), but that's fully Concurrent compatible. Has anyone managed to solve that yet? :)

@dai-shi
Copy link

dai-shi commented Mar 16, 2020

memoize(store => selector(store.get())),

Looks like you return a stable immutable result with getSnapshot. In this case, you don't need scoped subscriptions, because useMutableSource bails out if the snapshot is ref equal. Whether scoped subscriptions may improve the performance is unknown.

a global context, but that's fully Concurrent compatible.

I think it's possible. That's actually what I did: dai-shi/react-tracked#43
It doesn't pass the automated test with the tool, but works as I expect when I try it by hand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment