A small and unopinionated web framework meant to be simple, fast and lightweight.
Ricochet is a small web framework which uses the JSX
syntax and observable sequences to define layouts.
Its main ability is to render NestedNode
s into the DOM efficiently, where
a NestedNode
is defined as follows:
// A nested node can be...
type NestedNode =
// ... a DOM element,
| Element
// ... a primitive,
| number | string
// ... a list of nested nodes,
| ReadonlyArray<NestedNode>
// ... or an observable sequence of nested nodes.
| Observable<NestedNode>
Therefore, Ricochet can render any array, observable sequence or node into the DOM, and make sure the rendered node stays in sync with the value that was rendered, without rendering the node multiple times.
Ricochet does not optimize its operations by default, but makes it extremely easy to add features such as memoization, update batching and efficient list rendering.
Ricochet components are only rendered once (when they are instantiated), and updated when a reactive stream they depend on changes.
For instance, we can compare a clock in React and Ricochet.
const Clock = () => {
// State is introduced explicitely using 'useState'.
//
// The 'Clock' function will be called several times per second,
// but 'date' will be persisted thanks to 'useState'.
const [date, setDate] = React.useState(new Date())
// Effects are introduced using 'useEffect'.
React.useEffect(() => {
const interval = setInterval(() => setDate(new Date()), 1000)
return () => {
clearInterval(interval)
}
})
return (
<span>{date.toString()}</span>
)
}
Now here it is in Ricochet. Please note that subject
(defined in ricochet/reactive
), creates an observable similar to a
BehaviorSubject.
const Clock = () => {
// Since 'Clock' is only called once, there is no need for a
// tool such as 'useState'.
const date$ = subject(new Date())
// Once again, there is no need to wrap this logic at all.
const interval = setInterval(() => date$.next(new Date()), 1000)
// Resources are bound to elements and can be disposed of by
// using `element.destroy()`. To give ownership of a subscription
// to an element, we can use the 'attach' function.
attach({
unsubscribe() {
clearInterval(interval)
}
})
return (
// Ricochet has first class support for streams. When 'date'
// will be updated, the content of the span will be as well.
<span>{date$}</span>
)
}
Two different ways are provided to subscribe to event handlers on rendered elements.
The first way is to simply set an element's on*
property on creation, ie.
const onClickHandler = () => {
alert('Clicked!')
}
<button onclick={onClickHandler} />
The second way, unique to Ricochet, is to create a Connectable
, which can be
attached to an element during its creation. This provides an easy way to convert
an event handler into an observable sequence, while playing nicely with the
declarative JSX syntax.
const click$ = eventListener('click')
attach(
click$.subscribe(() => {
alert('Clicked!')
})
)
<button connect={click$} />
Another Connectable
is provided for data binding.
const name$ = subject('John')
const nameBinding$ = valueBinder(name$)
// Any change to 'name$' will update the value of the input, and any
// change to the input's value will update 'name$'.
<input type='text' connect={nameBinding$} />
Ricochet's goal is to avoid using a VDOM, and instead to create redraws, virtual or real, as rarely as possible. Since only the parts of the DOM that depend on a stream will be recomputed when the stream changes, allocations should be less common, and changes to the DOM should be as rare as if a diffing algorithm had been used.
However, in some cases a simple change to a reactive stream may impact large parts of the DOM, which will lead to a slower redraw. In these cases, performances may still be tuned easily in different ways.
Observable
streams are very powerful abstractions, and
can be manipulated to optimize their performances.
For instance, RxJS provides the following features for improving how streams are processed:
- Schedulers, which can be used to batch updates together, or to perform them at the right time.
- Operators such as
throttle
anddebounce
.
As stated in the overview, Ricochet can render many kinds of nodes. However, a supported type
of node that wasn't mentioned before is the CustomNode
.
Nodes that implement this interface are given full control over how they and their children are rendered, making operations such as batch processing and node caching extremely easy to implement.
For instance, here is how you would implement a node that only updates if a condition is respected:
class PredicateNode implements CustomNode {
constructor(
readonly node: NestedNode,
readonly predicate: (node: NestedNode) => boolean
) {}
render(parent: Element, prev: NodeRef, next: NodeRef, r: RenderFunction) {
// NodeRef is equivalent to [Node]; it is used to override variables accross function bodies.
if (!this.predicate(this.node))
return
r(this.node, prev, next)
}
}
Since all Ricochet elements are regular DOM elements, we may want to cache elements that we know we may have to reuse.
const App = ({ dialogProps, ...props }) => {
const dialogOpen$ = subject(false)
// Keep a reference to the dialog here, so that it is only rendered once
const dialog = <Dialog { ...dialogProps } />
return (
<div class='app'>
<Content { ...props } />
{ dialogOpen$.map(dialogOpen => dialogOpen && dialog) }
</div>
)
}
This is unfortunately not that simple. Here, as soon as dialog
gets rendered for the first time,
some resources will be attached to it via attach
. Then, as soon as dialogOpen$
becomes false
,
all these resources will be disposed of, and dialog
, while still existing, will be invalid.
Therefore, we must tell Ricochet that dialog
is owned by App
, and that it should
only be disposed when App
itself is disposed of.
// This line:
const dialog = <Dialog { ...dialogProps } />
// Becomes this line:
const dialog = <Dialog noimplicitdispose { ...dialogProps } />
Now, when dialog is removed by its parent element, it will not be disposed automatically.
In order to remove it, element.destroy(true)
must be called, true
precising that it
the element will be both destroyed (removed from the DOM) and disposed.
A common pattern is to cache an element in a component, and to dispose it when the parent
component itself is disposed, rather than when the element is hidden. This can be easily
accomplished by calling attach
with an element rather than with a subscription.
const TodoList = ({ todos }: { todos: Todo[] }) => {
const cache: Record<number, TodoItem> = {}
for (const todo of todos) {
const todoItem = <TodoItem text={todo.text} done={todo.done} noimplicitdispose />
attach(cache[todo.id] = todoItem)
}
const query$: Subject<string> = subject('')
return (
<div>
<input connect={valueBinder(query$)} />
<ul>
{ query$.map(query =>
todos
.filter(todo => todo.text.includes(query))
.map(todo => cache[todo.id])
) }
</ul>
</div>
)
}
In the above example, if noimplicitdispose
had not been used, each TodoItem
would have been
disposed as soon as it was filtered out by the query, making subsequent renders invalid.
Thanks to noimplicitdispose
, TodoItem
s will only be disposed when TodoList
itself is disposed.
Ricochet provides several utilities designed to make things faster, which are listed below. Other utilities may be added later on, such as keyed lists, memoization, and batch rendering.
The ricochet/array
module provides the
observableArray<T>
function, which takes an array
and returns an ObservableArray<T>
.
This specialized array provides the same interface as a regular array, but is able to efficiently
map changes to its underlying data to the DOM by implementing CustomNode
.
const numbers = observableArray<number>()
return (
<div>
{/* 'push' is specialized to directly add new nodes to the DOM,
without updating the rest of the elements. */}
<button onclick={() => numbers.push(numbers.length)}>Add number</button>
{/* 'sync' returns a `ReadonlyObservableArray` that is synced with its source. */}
{ numbers.sync(x => <h2>{x}</h2>) }
</div>
)
Jest unit tests are available in the src directory, and benchmarks will be added shortly.
Notice: Due to Jest (or rather jsdom)
not supporting Web Components and behaving differently from the browser,
tests currently fail if ran via Jest. While waiting for these bugs to be fixed,
the examples
page currently runs all tests in the browser,
ensuring that Ricochet keeps working as intended.
-
Sometimes, a component may want to receive either an observable value or a regular value, depending on what the caller needs. However, in most cases
T
andObservable<T>
have very different interfaces, which makes it hard to manipulate one or the other via a single API. In cases where anObservable<T>
could be accepted, it is best to always accept anObservable<T>
sequence, since such sequences can be made to emit a single element before completing, therefore acting exactly like a regular function (see:constant
,of
).
Since these observable sequences complete right after emitting an item, their resources will be disposed of quickly, and it will be as if a non-observable value had been used.Additionally, the built-in functions
combine
andcompute
both accept observable sequences as inputs, but may receive non-observable values, avoiding a useless subscription / unsubscription operation. -
Ricochet was designed with RxJS's API in mind, but works with many different reactive libraries. In fact, it only requires of observable values to define a way to subscribe to them; therefore the
constant
/of
observable may be implement as follows:function of<T>(value: T): Subscribable<T> & Observable<T> { const observable = { [Symbol.observable]() { return observable }, subscribe(observer: Observer<T>) { observer.next(value) observer.complete() } } return observable })
The core Ricochet API, used to render JSX nodes.
Defines an observer.
Defined as:
type Observer<T> = ((newValue: T) => void) | {
next(newValue: T): void
complete?(): void
}
Defines a subscription, which can be unsubscribed of.
Cancels the subscription, disposing of resources and cancelling pending operations.
Defines a value whose changes can be subscribed to.
Parameter | Type | Description |
---|---|---|
observer | Observer<T> |
None |
Subscribes to changes to the value.
Defines an observable value.
This interface defines the bare minimum for Ricochet to interface with reactive libraries that implement this ECMAScript Observable proposal, such as RxJS.
Defines a value that may be Observable
.
Defined as:
type MaybeObservable<T> = Observable<T> | T
An arbitrarily nested DOM Node
.
Defined as:
type NestedNode = Node | CustomNode | string | number | NodeArray | ObservableNode
An observable NestedNode
.
A list of NestedNode
s.
A mutable reference to a Node
.
Defined as:
type NodeRef = [Node]
The function used to render a NestedNode
.
Defined as:
type RenderFunction = (value: NestedNode, previous: NodeRef, next: NodeRef) => void
A custom-rendered node.
Parameter | Type | Description |
---|---|---|
parent | Element |
None |
previous | NodeRef |
None |
next | NodeRef |
None |
r | RenderFunction |
None |
Renders the node in the DOM, as a child of the given parent.
In Ricochet, nodes must be rendered between other nodes. Since a single CustomNode
may be rendered as several DOM nodes, these DOM nodes should be inserted before
next
, and previous
must be set to the first node that was inserted.
T
:Node
Defines an element that can be connected to a node.
Defined as:
type Connectable<T extends Node> =
((element: T, attachSubscriptions: (...subscriptions: Subscription[]) => void) => void)
| { connect: (element: T, attachSubscriptions: (...subscriptions: Subscription[]) => void) => void }
Props
:object
ReturnType
:Node | Observable<Node>
Defines a function that takes properties and returns a self-updating element.
Defined as:
type Component<Props extends object, ReturnType extends Node | Observable<Node>> = (props: Props) => ReturnType
Tag
:keyof JSX.IntrinsicElements
Parameter | Type | Description |
---|---|---|
tag | Tag |
None |
attrs | JSX.IntrinsicAttributes & WritablePart<JSX.IntrinsicElements[Tag]> |
None |
children | NodeArray |
None |
Renders an intrinsic element.
Parameter | Type | Description |
---|---|---|
tag | string |
None |
attrs | JSX.IntrinsicAttributes & WritablePart<Element> |
None |
children | NodeArray |
None |
Renders an unknown intrinsic element.
P
:object
E
:JSX.Element
K
:Component<P, E>
Parameter | Type | Description |
---|---|---|
component | K |
None |
props | JSX.IntrinsicAttributes & P & WritablePart<E> |
None |
children | NodeArray |
None |
Renders a component.
Parameter | Type | Description |
---|---|---|
subscriptions | `(Subscription | Element)[]` |
Attaches the given subscriptions or explicitly-disposed elements to the element that is currently being initialized.
Parameter | Type | Description |
---|---|---|
node | ObservableNode |
None |
Mounts an observable node as a simple element.
Parameter | Type | Description |
---|---|---|
node | NestedNode |
None |
el | Element |
None |
Mounts the given observable node as a child of the given element.
N
:Node
E
:Event
Parameter | Type | Description |
---|---|---|
type | string |
None |
opts | `boolean | AddEventListenerOptions` |
Returns a Connectable<T>
that can be used to register to events on one
or more elements.
Utilities for rendering lists efficiently with the ObservableArray
type.
Defines an object that can listen to changes to an ObservableArray
.
Defined as:
type ArrayObserver<T> = {
[key in string & keyof Array<T>]?:
Array<T>[key] extends (...args: infer Args) => any
? (...args: Args) => void
: never
} & {
set: (index: number, value: T) => void
}
Interfaces which exports all members implemented by ReadonlyObservableArray<T>
,
but not ReadonlyArray<T>
. Required by TypeScript.
Returns an observable sequence that emits whenever the length of this array changes.
Returns an observable sequence that emits whenever an item is modified.
Parameter | Type | Description |
---|---|---|
observer | ArrayObserver<T> |
None |
init | boolean |
If true , push will be called on initialization with the content of the array. |
Observes changes made to the array.
Parameter | Type | Description |
---|---|---|
f | (array: ReadonlyArray<T>) => R |
None |
thisArg | any |
None |
Returns an observable sequence that gets updated everytime this array changes.
Parameter | Type | Description |
---|---|---|
f | (value: T, index: number) => R |
None |
Propagates changes to the items of the given list to items of a new list,
according to a map
function.
Defines a readonly array whose changes can be observed.
Defines an array whose changes can be observed.
Parameter | Type | Description |
---|---|---|
f | (value: T, index: number) => R |
None |
g | (value: R, index: number) => T |
None |
Propagates changes to the items of the given list to items of a new list, and back again.
Parameter | Type | Description |
---|---|---|
a | number |
None |
b | number |
None |
Swaps the values at the two given indices.
Parameter | Type | Description |
---|---|---|
array | any |
None |
Returns whether the given array is an ObservableArray
.
Utilities for rendering with promises.
E
:Element
Parameter | Type | Description |
---|---|---|
component | Promise<E> |
None |
Returns an element that will be replaced by the result of the given promise when it resolves.
P
:{}
E
:Element
Parameter | Type | Description |
---|---|---|
component | (props: P) => Promise<E> |
None |
Wraps a component that asynchronously resolves as a regular component whose element will be replaced once the promise resolves.
P
:{}
E
:Element
K
:{}
Parameter | Type | Description |
---|---|---|
component | Component<P & K, E> |
None |
props | Promise<K> |
None |
Given a component, some of its properties, and a promise that resolves to the rest of its properties, returns an element that will be replaced by the resolved element when the promise finishes.
P
:{}
E
:Element
Parameter | Type | Description |
---|---|---|
component | Promise<Component<P, E>> |
None |
props | P |
None |
Given a promise that resolves to a component and its properties, returns an element that will be replaced by the resolved element when the promise finishes.
Utilities for creating and combining observable streams and subjects.
Defines a value that is both observable and observer.
Parameter | Type | Description |
---|---|---|
newValue | T |
None |
Updates the underlying value, notifying all observers of a change.
Parameter | Type | Description |
---|---|---|
value | any |
None |
Returns whether the given value is a subject created with subject
.
Subject
augmented with some specialized operations, returned by subject
.
Gets or sets the underlying value.
Parameter | Type | Description |
---|---|---|
value | T |
None |
Sets the underlying value without notifying observers.
Parameter | Type | Description |
---|---|---|
map | (input: T) => R |
None |
Returns a new Observable
that gets updated when this subject changes.
Parameter | Type | Description |
---|---|---|
map | (input: T) => R |
None |
unmap | (input: R) => T |
None |
Returns a new Subject
value that propagates changes to values both ways.
Parameter | Type | Description |
---|---|---|
initialValue | T |
None |
Returns a reactive wrapper around the given value.
Subscrible
that represents a constant value, returned by constant
.
Parameter | Type | Description |
---|---|---|
value | T |
None |
Returns an observable value that emits a single value, and then immediately completes.
This function should be used when an observable stream is expected somewhere, but a single constant value can be provided.
An Observable
that computes its values based on an arbitrary computation,
which may itself depend on other observable sequences; returned by compute
.
Parameter | Type | Description |
---|---|---|
computation | ($: <U>(observable: Observable<U>, defaultValue?: U) => U) => T |
None |
Returns an observable that will be updated when any of the given observables changes.
See S.js for the inspiration for this function. Please note that unlike S, changes are propagated immediately, without waiting for the next time unit.
const a = subject(1)
const b = subject(1)
const c = compute($ => $(a) + $(b))
c.subscribe(console.log).unsubscribe() // Prints '2'
a.next(10)
c.subscribe(console.log) // Prints '11'
b.next(20) // Prints '30'
Maps Observable<T>
properties of an object to T
.
Defined as:
type ObservableValueTypes<O> = {
[K in keyof O]: O[K] extends Observable<infer T> ? T : O[K]
}
An Observable
sequence that emits values when any of its dependencies
is updated; returned by combine
.
Interop helpers for RxJS.
Utilities for defining Web Components.
P
:object
E
:JSX.Element
Parameter | Type | Description |
---|---|---|
component | Component<P, E> |
None |
translateProperties | object & { [K in keyof P]: (value?: string) => P[K] } |
None |
Creates a custom element (or web component) out of a JSX component.