ramodel
v0.3.4
Published
Framework for creating reactive models
Downloads
20
Readme
RaModel
A library for creating reactive & flexible models with Hooks API. Can works together with React, Preact and Svelte.
API References • Demo TodoMVC example
- Hooks. It has Hooks like it made in React.
- Fast. It has many performance optimization to track changes only from needed instances.
- Typed. The library provide full coverage typings via TypeScript.
- Contexts. It has painless API to communicate with another execution contexts (eg. WebWorker, Node.js).
- Small. We try to minimize distributed size and use tiny dependencies.
Quick Start
1. Install the library from NPM
Execute this command in your project to install the library as new dependency:
npm install --save ramodel
Or if you using yarn:
yarn add ramodel
2. Define your first model.
Import createModel
method from the library
import { createModel } from 'ramodel';
If you are using TypeScript you can write type for the model's input. This type will be used when you construct new instance of model or update the instance's input via update
method.
interface CarProps {
fuelQuantity: number;
fuelConsumption: number;
}
Now you can define your model with createModel
:
const Car = createModel((props: CarProps) => {
// Main body which re-run on every update
// Here you can use Hooks from `ramodel/hooks` module
...
return {
// Public methods and properties
...
};
});
This is very similar to the React's functional components, but instead of returning elements, in RaModel you should return public methods and properties. Important: they are read-only, it helps you to use them without unpredictable state mutating from outside. You can mutate the model's state only with public methods
After defining you can construct new instance via new
operator:
const jeep = new Car({
fuelQuantity: 40,
fuelConsumption: 1,
});
The jeep
variable will have all defined public methods and properties before in Car
model.
Main the library's limitation is instances should be destroyed manually via destroy
method:
destroy(jeep);
3. Subscribe to instance's updates
To watch needed updates you can use the system of lenses. It is power functional concept let us get the whole information about using models in result of calculation. This information can be used in watch
method:
import { createLens, watch } from 'ramodel';
const odometerLens = createLens(motocycle, _ => _.odometer);
watch(odometerLens, odometer => console.log(`Motocycle's odometer: ${odometer}`));
// or you can use alias:
odometerLens.subscribe(odometer => console.log(`Motocycle's odometer: ${odometer}`));
To debug state's changes you can use createLogger
or connectReduxDevtools
method from devtools:
import { createLogger } from 'ramodel/devtools';
createLogger(motocycle);
// or
connectReduxDevtools(motocycle);
4. Advance using models from remote context
All power of RaModel in providing simple API to use reactive models from remote contexts. The remote context here may be Web Worker or window
from extension's background page or some remote server.
For example if you has some worker.js
script which will be executed in Web Worker contexts you can export your instances to the main process.
// worker.js - Worker's process
import { expose } from 'ramodel/remote/worker';
const world = expose();
world.set('jeep', jeep);
An then in main process you can import the jeep
instance:
// main.js - Main process
import { connect } from 'ramodel/remote/worker';
const worker = new Worker('worker.js', { type: 'module' });
const remoteWorld = connect(worker);
const jeep = await remoteWorld.get('jeep');
// Now you can use `jeep` like local model
// But it continue live in the worker process
Integration with popular frameworks
RaModel has very simple API which easy to ingrate with many popular library and frameworks.
Most often you only need to use lenses and a watch
method for subscribe updates.
React and Preact
import { useLens } from 'ramodel/react'; // or from 'ramodel/preact'
import { odometerLens } from './lenses';
import { driver } from './instances';
function App() {
const odometer = useLens(odometerLens);
const driverName = useLens(driver, _ => _.name);
return (
<div>
{driverName}: {odometer} miles
</div>
);
}
Svelte
All lenses are fully compliant Store contract in read-only mode. Because of this you can use lenses as reactive variables.
<script>
import { odometerLens } from './lenses';
</script>
<div>{$odometerLens} miles</div>
Angular, Vue and other frameworks
Sorry but I don't know these frameworks deeply to write good integration with them, if you want help me just create a new issue.
API References
ramodel
ramodel/hooks
- Basic Hooks
- Additional Hooks
- RaModel Hooks
ramodel/remote/generic
ramodel/remote/worker
ramodel/remote/global
ramodel/remote/local-storage
ramodel/remote/chrome
ramodel/devtools
createModel
import { createModel } from 'ramodel';
const Model = createModel(mainFn);
Create a new model which will use a passed function. This function can use Hooks and returns public methods and properties. Returns class for creating a model instance
const Model = createModel((props) => {
// Main body which re-run on every update
// Here you can use Hooks
...
return {
// Public methods and properties
...
};
});
// Create an instance of Model with passed props
const modelInstance = new Model(props);
If you want use reference to class as type you can use another way of defining models via class extends
:
class Model extends createModel(() => {
/* ... */
}) {}
If you want skip creating model and fast create needed instance you can use this shortcut:
import { createInstance } from 'ramodel';
const modelInstance = createInstance(input, mainFn);
update
import { update } from 'ramodel';
update(modelInstance, newInput);
Update input in the model and re-run main function
const User = createModel(({ firstName, lastName }) => {
return { name: `${firstName} ${lastName}` };
});
const john = new User({ firstName: 'John', lastName: 'Doe' });
console.log(john.name); // => 'John Doe'
// Update input and re-run main function in model
update(john, { firstName: 'Jesica', lastName: 'Brown' });
console.log(john.name); // => 'Jesica Brown'
destroy
import { destroy } from 'ramodel';
destroy(...modelInstances);
Shutdown all side effects and clean the state in models instances
createLens
import { createLens } from 'ramodel';
const lens = createLens(modelInstance, accessorFunction);
Create lens. Works like get()
but returns lens instead value
combineLenses
import { combineLenses } from 'ramodel';
const lens = combineLenses(lenses, handler);
Combine lenses in the one. It is very handly when you need to calculate value which depends on multiple lenses.
isLens
import { isLens } from 'ramodel';
if (isLens(maybeLens)) {
// `maybeLens` is definitely lens here
}
You can check your value that this is exactly a lens with isLens
method.
watch
import { watch } from 'ramodel';
const unsubscribe = watch(lenses, handler);
Watch for changes in models use lenses. The handler
recive values extracted with accessorFunction
. Returns function for unsubscribe
watch([lens], value => {
console.log(value);
});
createContext
import { createContext } from 'ramodel';
const Context = createContext(defaultValue);
Creates a Context object.
The defaultValue
argument is only used when a model does not have a matching Context's value above it in the tree. This can be helpful for testing models in isolation without wrapping them. Note: passing undefined
as a Context's value does not cause consuming models to use defaultValue
.
For provide value you can use Context.withValue
:
Context.withValue(newValue, () => {
// here you can create new model's instances
// they will get the `newValue` when we `useContext` hook
});
You can dynamicly update the Context's value in instance and its children tree with Context.updateValue
:
Context.updateValue(instance, newValue);
Also you can delete the Context from instance with Context.removeFrom
:
Context.removeFrom(instance);
get
import { get } from 'ramodel';
const value = get(object, accessorFunction);
Traverses properties on objects and arrays. If an intermediate property is either null or undefined, it is instead returned. The purpose of this method is to simplify extracting properties from a chain of maybe-typed properties.
Returns the property accessed if accessor function could reach to property, null or undefined otherwise
Consider the following type:
const props: {
user?: {
name: string,
friends?: Array<User>,
}
};
Getting to the friends of my first friend would resemble:
props.user && props.user.friends && props.user.friends[0] && props.user.friends[0].friends;
Instead, get
allows us to safely write:
get(props, _ => _.user.friends[0].friends);
The second argument must be a function that returns one or more nested member expressions. Any other expression has undefined behavior.
useState
import { useState } from 'ramodel/hooks';
const [state, setState] = useState(initialState);
Returns a stateful value, and a function to update it.
During the initialization, the returned state (state
) is the same as the value passed as the first argument (initialState
).
The setState
function is used to update the state. It accepts a new state value and enqueues a re-run main function of the model.
setState(newState);
During subsequent re-updates, the first value returned by useState
will always be the most recent state after applying updates.
Here’s the simple counter example:
const Counter = createModel(() => {
const [count, setCount] = useState(0);
return { count, setCount };
});
const counter = new Counter();
console.log(counter.count); // => 0
counter.setState(5);
console.log(counter.count); // => 5
counter.setState(count => count - 2);
console.log(counter.count); // => 3
setState
returns a promise which resolves when instance is updated before effects runs. It can be useful if you need wait model updating.
In main cases if you work with same instance it can be not needed because on every direct reading we try to finish all planned tasks for the instance before reading if it possible.
useEffect
import { useEffect } from 'ramodel/hooks';
useEffect(didUpdate);
Accepts a function that contains imperative, possibly effectful code.
Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a model. Doing so will lead to confusing bugs and inconsistencies in the state
Instead, use useEffect
. The function passed to useEffect
will run after a changes is committed to the model. Think of effects as an escape hatch from purely functional world into the imperative world.
By default, effects run after every completed change, but you can choose to fire them only when certain values have changed.
Cleaning up an effect
Often, effects create resources that need to be cleaned up before the model will be destroyed, such as a subscription or timer ID. To do this, the function passed to useEffect
may return a clean-up function. For example, to create a subscription:
useEffect(() => {
const subscription = props.source.subscribe();
return () => {
// Clean up the subscription
subscription.unsubscribe();
};
});
The clean-up function runs when the model passed into destroy
function to prevent memory leaks. Additionally, if a model changes multiple times (as they typically do), the previous effect is cleaned up before executing the next effect. In our example, this means a new subscription is created on every update. To avoid firing an effect on every update, refer to the next section.
Conditionally firing an effect
The default behavior for effects is to fire the effect after every completed change. That way an effect is always recreated if one of its dependencies changes.
However, this may be overkill in some cases, like the subscription example from the previous section. We don’t need to create a new subscription on every update, only if the source prop has changed.
To implement this, pass a second argument to useEffect
that is the array of values that the effect depends on. Our updated example now looks like this:
useEffect(() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
}, [props.source]);
Now the subscription will only be recreated when props.source changes.
The array of dependencies is not passed as arguments to the effect function. Conceptually, though, that’s what they represent: every value referenced inside the effect function should also appear in the dependencies array.
useReducer
import { useReducer } from 'ramodel/hooks';
const [state, dispatch] = useReducer(reducer, initialArg, init);
An alternative to useState
. Accepts a reducer of type (state, action) => newState
, and returns the current state paired with a dispatch
method. (If you’re familiar with Redux, you already know how this works.)
useReducer
is usually preferable to useState
when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. useReducer
also lets you optimize performance for components that trigger deep updates because you can pass dispatch down instead of callbacks.
Here’s the counter example from the useState
section, rewritten to use a reducer:
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
}
const Counter = createModel(() => {
const [{ count }, dispatch] = useReducer(reducer, initialState);
return {
count,
decrement: () => dispatch({ type: 'decrement' }),
increment: () => dispatch({ type: 'increment' }),
};
});
const counter = new Counter();
console.log(counter.count); // => 0
counter.increment();
console.log(counter.count); // => 1
counter.decrement();
console.log(counter.count); // => 0
dispatch
returns a promise which resolves when instance is updated before effects runs. It can be useful if you need wait model updating.
In main cases if you work with same instance it can be not needed because on every direct reading we try to finish all planned tasks for the instance before reading if it possible.
Specifying the initial state
There are two different ways to initialize useReducer
state. You may choose either one depending on the use case. The simplest way is to pass the initial state as a second argument:
const [state, dispatch] = useReducer(reducer, { count: initialCount });
Lazy initialization
You can also create the initial state lazily. To do this, you can pass an init
function as the third argument. The initial state will be set to init(initialArg)
:
function init(count) {
return { count };
}
const [state, dispatch] = useReducer(reducer, initialCount, init);
useMemo
import { useMemo } from 'ramodel/hooks';
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
Returns a memoized value.
Pass a “create” function and an array of dependencies. useMemo
will only recompute the memoized value when one of the dependencies has changed. This optimization helps to avoid expensive calculations on every update.
If no array is provided, a new value will be computed on every update.
You may rely on useMemo
as a performance optimization, not as a semantic guarantee. Write your code so that it still works without useMemo
— and then add it to optimize performance.
useCallback
import { useCallback } from 'ramodel/hooks';
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
Returns a memoized callback.
Pass an inline callback and an array of dependencies. useCallback
will return a memoized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child models that rely on reference equality to prevent unnecessary updates.
useCallback(fn, deps)
is equivalent to useMemo(() => fn, deps)
.
useRef
import { useRef } from 'ramodel/hooks';
const refContainer = useRef(initialValue);
useRef
returns a mutable ref object whose .current
property is initialized to the passed argument (initialValue
). The returned object will persist for the full lifetime of the component.
Essentially, useRef
is like a “box” that can hold a mutable value in its .current
property.
It’s handy for keeping any mutable value around similar to how you’d use instance fields in classes:
const FormObserver = createModel(({ subscribe }) => {
// We use `useRef` here instead `useState` because nobody depends on that state
// and we don't want to re-run main function on receive new form
const form = useRef(null);
useEffect(
() =>
subscribe(newForm => {
form.current = newForm;
}),
[subscribe],
);
return {
focus: () => {
if (form.current) {
form.current.focus();
}
},
};
});
This works because useRef()
creates a plain JavaScript object. The only difference between useRef()
and creating a {current: ...}
object yourself is that useRef will give you the same ref object on every update.
Keep in mind that useRef
doesn’t notify you when its content changes. Mutating the .current
property doesn’t cause a re-update like useState
.
useLens
import { useLens } from 'ramodel/hooks';
const value = useLens(lens);
// or you can embed lens creating:
const value = useLens(instance, _ => _.value);
With useLens
hook you can get current value from the lens (which can created with createLens
or combineLenses
) and all subsequent values in updates. Also you can embed lens creating to this hook if you pass arguments to this hook as for createLens
method.
useModel
import { useModel } from 'ramodel/hooks';
const child = useModel(Model, {
input: {},
contexts: [
[ContextA, contextValueA],
[ContextB, contextValueB],
],
});
useModelFabric
import { useModelFabric } from 'ramodel/hooks';
const createInstance = useModelFabric(Model, {
// Here you can pass list of pairs of context and value
// It supports dynamic updates of values for new and all previously created instances
contexts: [
[FooContext, 'foo'],
[BarContext, 'bar'],
],
});
const instance = createInstance({ foo: 'bar' });
Generic connect
import { connect } from 'ramodel/generic/generic';
const clientId = 'website';
const webSocket = new WebSocketChannel();
const remoteWorld = connect({
postMessage: msg => sendMessage(msg, { clientId }),
onInit: ({ onMessage }) => {
webSocket.listen('message', data => {
const { message, ...meta } = JSON.parse(data);
if (clientId !== meta.clientId) return;
onMessage(message);
});
},
});
const myRemoteModel = await remoteWorld.get('my-model');
// Now you can use `myRemoteModel` like local model
// But it continue live in the background page's process
Generic expose
import { expose } from 'ramodel/remote/generic';
const webSocket = new WebSocketChannel();
const world = expose({
onInit: ({ onMessage }) => {
webSocket.listen('message', data => {
const { message, ...meta } = JSON.parse(data);
if (!meta.clientId) return;
onMessage(message, data => sendMessage(key, data, { clientId: meta.clientId }));
});
},
});
world.set('my-model', myLocalModel);
Worker connect
import { connect } from 'ramodel/remote/worker';
const worker = new Worker('worker.js', { type: 'module' });
const remoteWorld = connect(worker);
const myRemoteModel = await remoteWorld.get('my-model');
// Now you can use `myRemoteModel` like local model
// But it continue live in the worker process
Worker expose
// Worker's process
import { expose } from 'ramodel/remote/worker';
const world = expose();
world.set('my-model', myLocalModel);
Global connect
import { connect } from 'ramodel/remote/global';
const backgroundWindow = chrome.extension.getBackgroundPage();
const remoteWorld = connect(backgroundWindow);
const myRemoteModel = await remoteWorld.get('my-model');
// Now you can use `myRemoteModel` like local model
// But it continue live in the background page's process
Global expose
import { expose } from 'ramodel/remote/global';
const backgroundWindow = chrome.extension.getBackgroundPage();
const world = expose(backgroundWindow);
world.set('my-model', myLocalModel);
LocalStorage connect
import { connect } from 'ramodel/remote/local-storage';
const remoteWorld = connect();
const myRemoteModel = await remoteWorld.get('my-model');
// Now you can use `myRemoteModel` like local model
// But it continue live in the background page's process
LocalStorage expose
import { expose } from 'ramodel/remote/local-storage';
const world = expose();
world.set('my-model', myLocalModel);
Chrome connect
import { connect } from 'ramodel/remote/chrome';
const remoteWorld = connect();
const myRemoteModel = await remoteWorld.get('my-model');
// Now you can use `myRemoteModel` like local model
// But it continue live in the background page's process
Chrome expose
import { expose } from 'ramodel/remote/chrome';
const world = expose();
world.set('my-model', myLocalModel);
createLogger
import { createLogger } from 'ramodel/devtools';
createLogger(instance, {
name: 'my awesome instance',
diff: true,
});
connectReduxDevtools
import { connectReduxDevtools } from 'ramodel/devtools';
connectReduxDevtools(instance, { name: 'my awesome instance' });
You can connect Redux DevTools to debug your model instance. It has a very basic integration with it, some features may not worked.
Thanks
This project based on source code of "haunted".
- Thanks Gleb Arestov and him project Deklarota for inspire me to create this library for reactive model management.
- Thanks Matthew Phillips and other contributors for their big work under re-implementing Hooks API.
- Also big thanks to React's documentation authors for their very clear documentation about Hooks conceptions.