@srhazi/revise v0.5.0
Revise is a tool to ~re~build user interfaces and applications.
Motivation
Revise embraces mutation: when things change, it knows exactly what needs to be updated and updates things in place.
It's inspired by build systems (specifically by tup), which need to understand how to rebuild files when dependent files change.
Concepts
There are four main concepts in Revise: 1. A model is an ordinary JavaScript object that is watched for changes. 2. A collection is an ordinary JavaScript array that is watched for changes. It additionally has a few extra functions for convenience. 3. A calculation is a function which takes no arguments and returns a value. 4. A component is a function which takes props and returns JSX.
For example, here is a simple counter application:
import Revise, { model, calc, mount, subscribe, flush } from 'revise';
const Counter = () => {
const state = model({ count: 0 });
const onIncrement = () => {
state.count += 1;
};
return (
<div>
<p>Counter: {calc(() => state.count)}</p>
<button on:click={onIncrement}>
Increment
</button>
</div>
);
};
mount(document.body, <Counter />);API
Many functions take an optional debugName parameter. This parameter gives the returned object a name that is used for
diagnostic logging and other debugging purposes.
Calculations
calc(fn)
type EqualityFunc<T> = (a: T, b: T) => boolean
function calc<Ret>(func: () => Ret, isEqual: EqualityFunc<Ret>, debugName: string): Calculation<Ret>
function calc<Ret>(func: () => Ret, isEqual: EqualityFunc<Ret>): Calculation<Ret>
function calc<Ret>(func: () => Ret, debugName: string): Calculation<Ret>
function calc<Ret>(func: () => Ret): Calculation<Ret>The calc function produces a Calculation function, which is a type of function which keeps track of dependencies
read during its execution. Dependencies are all fields on Model types, all items within a View or Collection, or
any other non-effect Calculation. If any of these dependencies change, the calculation is re-executed (on the next
flush).
When used within JSX, calculations are automatically re-rendered when their dependencies change. Calculations may be passed directly as JSX nodes, or as props to native elements. No special behavior is performed when passed as props to Components.
The isEqual function may be passed as an optimization. If a calculation's isEqual function returns true,
calculations which are dependent on this calculation will reuse the prior calculated value, and not need to be
recalculated. This parameter should only be used as an optimization.
For example, here is a counter component that uses calc:
const Counter = () => {
const state = model({
count: 0,
});
const onIncrement = () => {
state.count += 1;
};
const isOver10 = calc(() => state.count > 10);
return (
<div>
<p>Current count: {calc(() => state.count)}</p>
{calc(() => isOver10() && <p>That's enough!</p>)}
<button disabled={isOver10} on:click={onIncrement}>
+1
</button>
</div>
);
};Note: the returned calc must be called before it may be re-executed. This is handled by default when a calculation is
placed in JSX.
Note: if used outside of jsx, calculations must be manually retain()ed and release()d, otherwise they will not be
recalculated.
effect(fn)
function effect(func: () => void, debugName?: string | undefined): Calculation<void>The effect function produces a Calculation that does not return any values, and does not behave as a dependency.
Like calc, it will be re-executed if any of its dependencies change. Effects can be used to respond to changes in
dependencies.
Note: effects must be called once before they are re-executed. They must be manually retained and release
Data
model(init, debugName)
function model<T>(init: T, debugName?: string): Model<T>The model function produces Model types, which act just like normal JavaScript objects.
model.keys(target)
(method) model.keys<T>(target: Model<T>): View<string>The model.keys function produces a View holding the keys of the model. The returned View is a collection which
holds keys in the provided model. This view is automatically updated as keys in the model are added (via assignment) or
removed (via delete).
collection(items)
function collection<T>(array: T[], debugName?: string): Collection<T>The collection function produces Collection types, which act just like normal JavaScript arrays, with some
additional methods.
.reject(shouldReject)
(method) Collection<T>.reject(shouldReject: (item: T, index: number) => boolean): void`The reject method mutates the collection to remove all items which pass the provided shouldReject predicate test.
.mapView(mapFn, debugName)
type MappingFunction<T, V> = (item: T) => V
(method) Collection<T>.mapView<V>(mapFn: MappingFunction<T, V>, debugName?: string): View<V>The mapView method produces a View holding transformed items from the collection. This view is automatically and
efficiently updated as items in the collection are added (via push, unshift, splice), removed (via pop, shift,
splice, reject), items reassigned, or mutated via any other means.
Note: the automatic update is not extended to data read while the mapFn method is performed. mapFn gets called
once per item, when the item is added to the collection.
.flatMapView
type MappingFunction<T, V> = (item: T) => V
(method) Collection<T>.flatMapView<V>(flatMapFn: MappingFunction<T, V[]>, debugName?: string | undefined): View<V>The flatMapView method produces a View holding transformed items from the collection. This view is automatically and
efficiently updated as items in the collection are added (via push, unshift, splice), removed (via pop, shift,
splice, reject), items reassigned, or mutated via any other means.
Note: the automatic update is not extended to data read while the flatMapFn method is performed. flatMapFn gets
called once per item, when the item is added to the collection.
.filterView
type FilterFuction<T> = (item: T) => boolean
(method) Collection<T>.filterView(filterFn: FilterFunction<T>, debugName?: string | undefined): View<T>The filterView method produces a View holding filtered items from the collection. The provided filterFn determines
if the item will exist in the retured view.
This view is automatically updated as items in the collection are added (via push, unshift, splice), removed (via
pop, shift, splice, reject), items reassigned, or mutated via any other means.
Note: the automatic update is not extended to data read while the filterView method is performed. filterFn gets
called once per item, when the item is added to the collection.
.moveSlice
(method) Collection<T>.moveSlice(fromIndex: number, fromCount: number, toIndex: number): voidThe moveSlice method allows for moving portions of an array to other indexes. When mounted collections of JSX produced
by mapView is moved, the corresponding DOM nodes are moved without any unmounting/mounting or rerendering.
View types
Collection's .mapView, .filterView, and .flatMapView functions produce Collection types, which act just like
read-only JavaScript arrays.
These views have the same .mapView, .filterView, and .flatMapView functions that exist on Collection types.
DOM
mount(target, jsx)
function mount(target: Element, jsx: JSXNode): () => voidThe mount function mounts the provided jsx at the provided target DOM node. It returns a function which unmounts
the provided jsx.
ref()
type Ref<T> = { current: T | undefined };
function ref<T>(val?: T): Ref<T>;The ref function produces ref objects which can be passed to native JSX elements. When mounted, the ref's current
property is set to the reference of the native HTML element.
Example:
const CanvasText: Component<{ text: string }> = ({ text }, { onMount }) => {
const canvasRef = ref<HTMLCanvasElement>();
onMount(() => {
const ctx = canvasRef.current.getContext("2d");
ctx.font = "50px serif";
ctx.lineWidth = 5;
ctx.strokeStyle = "#FF0000";
ctx.fillStyle = "#FFDDDD";
ctx.font = "50px serif";
ctx.strokeText(text, 10, 75, 480);
ctx.fillText(text, 10, 75, 480);
});
return <canvas ref={canvasRef} width="500" height="100" />;
};
mount(document.body, <CanvasText text="Hello, World!" />);Components
The Component type
type PropsWithChildren<Props> = Props & { children?: JSXNode[] };
type ComponentListeners = {
onUnmount: (callback: OnUnmountCallback) => void;
onMount: (callback: OnMountCallback) => void;
onEffect: (callback: EffectCallback) => void;
getContext: <ContextValue>(context: Context<ContextValue>) => ContextValue;
};
type Component<Props extends {}> = (
props: PropsWithChildren<Props>,
listeners: ComponentListeners
) => JSXNode;Components are user-defined functions which take props and lifecycle event handlers and return JSX.
Components are never re-rendered. There are two (and only two) lifecycle events to every component:
- Callback functions passed to the
onMountfunction are called immediately after the component has been mounted to the DOM - Callback functions passed to the
onUnmountfunction are called immediately before the component has been mounted to the DOM
The getContext function allows the component to read context values. See the createContext section below.
The onEffect function allows the creation of effects which are scoped to the lifetime of the component. These effects
are guaranteed to trigger after mounted calculations in JSX, so it is safe to read from the component's DOM subtree
in the effect. Note: this is guaranteed for the mounted children of the component, not for parent subtrees.
To demonstrate the use of onEffect, here's a "log" component which takes a collection of log messages, and scrolls the
container so that the last log message is visible when additional log messages are added:
const Log: Component<{ messages: Collection<string> }> = (
{ messages },
{ onEffect }
) => {
const logRef = ref<HTMLPreElement>();
onEffect(() => {
if (logRef.current && messages.length > 0) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
});
return (
<pre class="log" ref={logRef}>
{messages.mapView((message) => `${message}\n`)}
</pre>
);
};createContext()
Contexts can be used to set values that can be retrieved from child subtrees.
function createContext<T>(value: T): ContextProvider<T>Create a new context that has a default value. The returned ContextProvider can be used to set the value of this context for a JSX subtree.
For example:
const ColorContext = createContext<'red' | 'blue'>('red');
const MyComponent: Component<{ message: string }> = ({ message }, { getContext }) => (
<div style={`color: ${getContext(ColorContext) === 'red' ? '#FFDDDD' : '#DDDDFF'}`}>
{message}
</div>
);
mount(document.body, (
<div>
<ColorContext value="blue">
<MyComponent message="This text would be blue" />
</ColorContext>
<MyComponent message="And this text would be red, the default" />
</div>
));Behavior
flush()
function flush(): voidManually trigger a recalculation of all calculations and effects that have had their dependencies changed. By default, you will never need to call this function, it gets automatically called after a timeout.
nextFlush()
function flush(): Promise<void>Get a promise which will resolve on the next flush (or immediately, if there is no pending flush).
subscribe(onReadyToFlush)
function subscribe(onReadyToFlush: () => void): voidBy default, Revise will call flush automatically once any calculation/effect dependencies have changed after a
timeout. If you wish to configure this behavior, use the subscribe function, which will be called once when a
flush() is necessary.
debug()
function debug(): stringDump the current dependency graph in a graphviz DOT file format. The
debugName values passed to models, collections, calculations, and effects will be represented in this directed graph.
retain(obj); release(obj);
function retain(item: Calculation<any> | Collection<any> | View<any>): void;
function release(item: Calculation<any> | Collection<any> | View<any>): void;Note: In typical use, you should not need to use these functions unless you are using effect or calc outside of
components. There is no need to ever pass a Collection or View to these methods; this is only required internally
within Revise.
Revise automatically stops processing items that are leaf nodes in the dependency tree: if no calculation or effect has a dependency on an item, that item is no longer processed further.
The retain function adds to the reference count of item; the release function removes from the reference count of
item. If this reference count is non-zero, the item will be recalculated when its data dependencies change.
If you are using the exported effect or calc functions outside of calculations, you must manually retain and
release these functions, otherwise they will not be processed when their dependencies change.
reset()
function reset(): voidResets the entire state (releases all retained objects, drops the dependency graph). You should never need to do this aside from within a test.
Differences from React
Revise has fewer moving parts than React. There are no component classes, no component state, no hooks, and no lifecycle events.
Component functions get called exactly once in their lifecycle: when they are to be rendered. Components do not re-render. Instead of lifecycle events, component functions are passed a second parameter which is an object containing a few subscription callbacks. These callbacks are:
onUnmount(callback: () => void): called immediately before all of the DOM nodes rendered by the component are removed from the DOM.onMount(callback: () => void): called immediately after all of the DOM nodes rendered by the component have been been added to the DOM.
Native elements behave slightly differently:
- The
classNameprop is not used. Useclassinstead. - The
styleprop is not an object, it is astring.
There currently is no cloneElement equivalent.
There is no isValidElement or React.Children equivalent.
Contexts returned by createContext are opaque values. There is no MyContext.Provider or MyContext.Consumer; to
read a context, a component must use its provided getContext() callback.
The ref function is equivalent to createRef() / useRef() equivalent. Refs notably only have default behavior when
placed on native JSX elements. On component functions, the ref property is not specially treated and may be used like
any other property. If you want something akin to useImperativeHandle, give your component a ref prop and assign the
interface to ref.current in either the component body or within its onMount handler.
The children component prop is an array of JSX elements. There is no difference between a Fragment and an array: In
fact, the definition of Fragment is: ({ children }) => children.
Events are native browser events, and use the standardized DOM event type
name. For example, to listen to the mousemove event, pass
a on:mousemove={onMouseMove} prop to an element. Events are bound directly to the element which you are placing an
event handler on. This means custom
events may be used safely. This
also means that focus and blur events do not bubble. If you want to pay attention to focus entering/leaving a child,
listen for focusin and
focusout events.