⚠️ This is very unstable, please do not use this in production. Contribution welcome!
This is the core engine for a reactive effects system that makes use of generators.
It is not intended to be used directly as it is abstract enough to support different observable formats.
It works a bit like useEffect
in React, except instead of having a dependencies array you yield
things you want to depend on.
If any of your dependencies change the effect restarts.
There's various meta hooks you can use to listen to the different of the effect's lifecycle. You can also make anything a reactive dependency using a simple observable API.
The core principle could be applied in many different contexts, in different view frameworks, or even server-side. We wanted to know the basic logic of the effects system was rock solid so we've put it in its own repo and written a lot of tests to verify different corner cases work as expected.
Generators allow us to do alot of things that aren't possible otherwise. E.g.
- Synchronously tracking references to dependencies
- Early cancellation of an execution context (for e.g. restarts, component unmounts)
- Add custom handlers for custom data types that are yielded
let api = createEffect<T>(options: Options): API<T, Initial>
type Options = {
handlers: Handler[]
, visitor: (ctx: Initial) => Generator<any, U, any>
, interceptNext?: (it: Iterator<any, U, any>, next: any) => any
}
Create an effect by providiing a visitor
GeneratorFunction and a list of handlers
.
Handlers let you control how yielded values are handled. E.g. if you want to add support for a particular stream library you might add a handler like so:
import * as fx from 'super-mithril-effects'
let effectApi = createEffect({
visitor: function * (){
let a = yield myStream
let b = yield otherStream
},
handlers: [streamHandler]
})
Where streamHandler
looks like this:
const streamHandler = (x) => {
if (!isStream(x)) {
return fx.SKIP
}
return (onchange) => {
let mapper = x.map(onchange)
return () => {
mapper.end(true)
}
}
}
interceptNext
allows you to control the execution of iterator.next(nextValue)
We provide this API so we can track creation and references of streams and stores, but you may also use it just for simple logging / debuging.
let effectApi = createEffect({
...,
interceptNext(it, next){
console.log('injecting', next)
const value = it.next(next)
console.log('got back', value)
return value
}
})
export type API<U=any, Initial=U> = {
start(initial?:Initial): void
stop(): void
context: EffectContext<U>
on( cb: (x:U) => void ): (x:U) => void,
off( cb: (x:U) => void ): void,
running: Promise<void>,
addDependency(x: any): void
}
This is what createEffect
returns.
Starts the generator function and handler. Calling this function when the generator is already started does nothing.
Stops the generator function and handler and cleans up all listeners. Calling this function when the generator is already stopped does nothing.
Subscribe to the return value of a generator.
const cb = x => console.log(x)
effectApi.on( cb )
Unsubscribe to the return value of a generator.
const cb = x => console.log(x)
effectApi.off( cb )
A promise that resolves when the effect is no longer running.
Manually add a dependency that will restart the generator when it emits 2 times. Just like a normal yield
the first emit is assumed to be the current value and is valid in the current iterator session. The second emit invalidates the current session and triggers a restart.
addDependency
uses the same handler list that you passed in on initialization.
If you want to restart after the first emit you can do the lower level API: effectApi.restart()
which requests a restart directly.
The effect context is designed to be accessible to the framework user. You may want to pass it in as the initial
value to effectApi.start(...)
along with other framework or application specific context.
The context allows you to subscribe to specific lifecycle events of the effect and it gives you special utils like block
and sleep
.
export type EffectContext<U> = {
onFinally: ( f: () => any ) => void
onReplace: ( f: () => any ) => void
onReturn: ( f: (data: U) => any ) => void
onThrow: ( f: (error: any) => any ) => void
sleep: (ms: number) => SleepEffect
block: () => BlockEffect
}
Called whenever the generator function is exiting. This could be due to any of the following:
- A restart of the generator due to a dependency changing
- An exception was thrown
- The generator completed and returned a value
- The effect is being teardown after
effectApi.stop()
was called
Called whenever the generator is restarting and the current instance is being replaced.
This is called immediately before starting the new instance so you will still have access to the closure of the old instance.
Called only after the generator has exited without error.
Called only after the generator has exited via an exception being thrown.
Will pause the generator for the specified amount of ms
.
Will permanently block the generator from continuing execution. Useful if you want to wait for a dependency change to restart the effect.
When a dependency observable changes the current iterator is cancelled and restarted. The cancellation works by calling it.throw(new Cancellation())
If you have a try
/ catch
in your generator you can check if the error is a cancellation via err instanceof zed.Cancellation
. Note you shouldn't try to guard against cancellation, this library will stop iterating generators that have been cancelled, but you may want to rule out Cancellation
exceptions for logging / debugging purposes.
Typescript doesn't really support generators very well. The good news is there are various open issues on the Typescript repo and it looks the core team takes the gap in support very seriously.
The best we can do in the meantime is manually cast our yields and pray for a better future.