Skip to content

Instantly share code, notes, and snippets.

@romgrk
Last active March 12, 2024 14:12
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 romgrk/28a6106c56c05f5f932d5d53dbcecfef to your computer and use it in GitHub Desktop.
Save romgrk/28a6106c56c05f5f932d5d53dbcecfef to your computer and use it in GitHub Desktop.
DataGrid event-based reactivity
//
// Event-based reactivity with events corresponding 1:1 with state properties
// I use "path" and "event" interchangeably.
//
// Example state model
type State = {
virtualization: {
offsetTop: number,
renderContext: {
firstRow: number,
firstColumn: number,
}
}
}
// Our new selector interface: a function with its dependencies attached
interface Selector<T = unknown> {
(state: State): T;
// Dependencies as a list of paths like "virtualization.renderContext"
dependencies: string[];
}
// An example path selector
// NOTE: This is the biggest change, we need to wrap our selectors with this helper.
const renderContextSelector = createPathSelector(state => state.virtualization.selector)
// An example derived selector, like we already have
const renderContextColumnSelector = createSelectorMemoized(
renderContextSelector,
(context) => ({
firstRow: -1,
firstColumn: context.firstColumn,
})
)
// Path selector helper, attaches the path for a given selector
function createPathSelector<T>(selector: (state: any) => T) {
function proxyFor(path: string[] = []) {
return new Proxy({}, {
get: (_, part: string) => part === '__path__' ? path : proxyFor(path.concat(part)),
})
}
const state = proxyFor([])
const path = (selector(state) as any).__path__
const result = selector as Selector
result.dependencies = [path.join('.')]
return result
}
// Existing function, only difference is we accumulate dependencies from sources to the
// newly created selectors.
function createSelectorMemoized(...args: Function[]) {
const sources = args.slice(0, -1) as Selector[]
const selector = args.slice(-2, -1)[0] as Selector
selector.dependencies = Array.from(new Set(sources.map(s => s.dependencies).flat()))
function memoizeSelector(..._) { /* same logic we already have */ }
return memoizeSelector(...sources, selector)
}
// Next our store is adapted to have a map of all listeners
type Listener<T> = (value: T) => void;
class Store<T = State> {
value: T;
// Map of listeners, in the format:
// "virtualization" => Set<(value: T['virtualization']) => void>
// "virtualization.renderContext" => Set<(value: T['virtualization']['renderContext']) => void>
listeners: Map<string, Set<Listener<T>>>;
constructor(value: T) {
this.value = value;
this.listeners = new Map();
}
// Subscribe to a given path/event
subscribeToPath = (path: string[], fn: Listener<T>) => {
const pathKey = path.join('.')
let listeners = this.listeners.get(pathKey);
if (!listeners) {
listeners = new Set();
this.listeners.set(pathKey, listeners);
}
listeners.add(fn);
return () => { listeners!.delete(fn) }
}
// Update a path
// This would replace `.setState` calls, because `.setState` isn't aware of which paths inside the
// state are updated.
updatePath = (path: string[], value: any) => {
this.value = setValueByPath(this.value, value, path)
// Dispatch the event along the ancestor listeners, e.g. if someone is listening
// to "virtualization" and we just updated "virtualization.renderContext", then
// we need to emit the event for "virtualization" as well.
for (let i = 0; i < path.length; i++) {
const currentPath = path.slice(0, i + 1)
const listeners = this.listeners.get(currentPath.join('.') as any)
if (listeners) {
const currentValue = getValueByPath(this.value, currentPath)
listeners.forEach((l) => l(currentValue));
}
}
}
}
// And finally the new `useGridSelector` function.
function useGridSelector(apiRef: any, selector: Selector) {
const [state, setState] = useState(() => selector(apiRef.current.state))
useOnMount(() => {
const disposables =
selector.dependencies.map(path =>
apiRef.current.store.subscribeToPath(path, setState))
return () => disposables.forEach(f => f())
})
return state
}
@cherniavskii
Copy link

The changes seem to be non-breaking (unless you have custom state selectors – they will need to be wrapped with createPathSelector after this change).
Do you think it's safe to do this in v7 stable?

@romgrk
Copy link
Author

romgrk commented Mar 12, 2024

I think it should be ok. I have spotted a few tricky cases but it should be fine.

Btw are pipe processors part of the public API? Removing some of them might help, because they're kindof like selectors in some cases.

@cherniavskii
Copy link

Btw are pipe processors part of the public API?

It's private. The only public thing is unstable_applyPipeProcessors, but it's unstable and we can change it if necessary.

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