-
Notifications
You must be signed in to change notification settings - Fork 8
Description
Zedux is very loose in terms of enforcing how you manage state. Atoms can always be updated directly via .setState, .setStateDeep, .dispatch, and all those same methods on the .store itself. All this is in addition to any exports you set on the atom.
This is on purpose for maximum flexibility, but sometimes it can be nice to enforce that an atom is only updated via its exports. That is often the entire point of defining an atom's API.
To this end, and as discussed in #131, I propose adding a new concept of readonly atoms.
Creation
Simply set readonly: true in the atom config.
const readonlyExampleAtom = atom(
'readonlyExample',
'some constant state',
{ readonly: true }
)This is probably the only API Zedux will expose for creating readonly atoms. Users can create a readonlyAtom factory if they want that does this for them:
export function readonlyAtom<
Exports extends Record<string, any>,
Params extends any[],
State,
StoreType extends Store<State>,
PromiseType extends Promise<any> | undefined
>(
key: string,
value: AtomValueOrFactory<{
Exports: Exports;
Params: Params;
Promise: PromiseType;
State: State;
Store: StoreType;
}>,
config?: Omit<AtomConfig<State>, 'readonly'>
) {
return atom(key, value, { ...config, readonly: true });
}Internals
The new GraphNode class in Zedux v2 is already a "readonly" node (essentially a "readonly signal"). We basically have this setup right now:
GraphNode
|
SignalInstance
|
AtomInstance
I believe the simplest way to implement this will be this:
GraphNode
/ \
SignalInstance ReadonlyAtomInstance
|
AtomInstance
Readonly atoms are not signals, but they are graph nodes. This gives them most features they need - allowing dependents to subscribe to updates, listen to events, and statically get the state. But it would require duplicating or abstracting all atom-specific features (exports, promises, everything about the atom api) out of the AtomInstance class. The latter is a good thing anyway for bundle size, so I'm in favor of this approach, even though it also means AtomInstance does not extend ReadonlyAtomInstance which I just feel like I would expect.
Using
Readonly atoms and their ReadonlyAtomTemplates will work with all current Zedux APIs (atom getters, hooks, injectors) except useAtomState and injectAtomState. All those APIs will need a new overload (accepting a generic GraphNode won't work for most of them, but maybe atom getters at least can do that). The *AtomState functions will simply not have this overload.
Readonly atom instances will have similar methods as normal atoms, minus any methods for directly updating the state.
const readonlyInstance = useAtomInstance(readonlyExampleAtom)
readonlyInstance.get() // good
readonlyInstance.set() // bad, `.set` doesn't exist
readonlyInstance.mutate() // bad, `.mutate` doesn't exist
readonlyInstance.exports.updateStateTheRightWay() // goodTodo
Some stuff we'd need to figure out first:
Events
I'm not sure if events should be removed too.
readonlyInstance.send('myEvent') // good or bad?Should this work, or should .send be removed from readonly atoms? If it is hidden, listening to events will still be possible directly off the readonly atom. Sending an event can only be done via an export that calls .send on an injected+returned signal. Full example:
import { api, As, atom, injectSignal } from '@zedux/react'
const readonlyExampleAtom = atom('readonlyExample', () => {
const signal = injectSignal('example state', {
events: {
customEvent: As<number>
}
})
return api(signal).setExports({
sendCustomSignal: (num: number) => signal.send('customEvent', num)
})
}, { readonly: true })
function ExampleComponent() {
const readonlyInstance = useAtomInstance(readonlyExampleAtom)
useEffect(() => {
const cleanup = readonlyInstance.on('customEvent', num => {
console.log('got customEvent number!', num)
})
return cleanup
}, [readonlyInstance])
return (
<button onClick={() => readonlyInstance.exports.sendCustomEvent(2)}>
Send the number 2!
</button>
)
}Invalidation
Does instance.invalidate count as a direct-updater that should be removed in readonly atoms? I'm currently leaning towards no, since hiding that would require adding a new API for users to be able to still do it internally. Currently to self-invalidate, you injectSelf().invalidate(). Quick example:
const self = injectSelf()
// then to use it:
return api().setExports({ doInvalidate: () => self.invalidate() })But if we remove instance.invalidate, that would also remove it from injectSelf() (which simply returns a reference to the atom instance itself).
I don't see allowing invalidation to still exist causing problems. And the entire point of it is really to allow external consumers to invalidate the atom. Invalidating the atom from inside its own state factory is rare. I think it would be pretty annoying to enforce that invalidation always goes through an export.
Timeline
I'm thinking of not biting this off as part of Zedux v2. Plus with the current proposed API, it would require no breaking changes after v2, only some new overloads.