Writing your own extension
An extension is effectively a factory of a set of callbacks. All callbacks are optional.
onCreate
callback returns implementation for extension methods and properties which are added to a State object, where this extension is activated. If your extension does not add any properties or methods, do not provide onCreate
callback or return {}
from it.
Here is an example of an extension which has got all possible callbacks and prints console logs when callbacks are called. It also defines an extension method and an extension property. The example is relatively long, because we provided extensive comments and mentioned other available capabilities, which we did not use in this instance.
For more information, check out how the existing standard plugins are implemented. In case of any issues, just raise a ticket on Github.
Counter value: 0
import React from 'react';import { State, ExtensionFactory, useHookstate, SetStateAction, InferStateValueType, hookstate, extend } from '@hookstate/core';import { Identifiable, identifiable } from '@hookstate/identifiable';// An example of state extension method and property,// which we will implement in the extension belowexport interface Counted {// It holds a counter of how many times State.set method was called,// We make it as a State, so we can subscribe a React component to it elsewhere.// However, you most frequently would not need to have properties as State,// returning non State results is totally fine.counter: State<number>,// This is an alternative to State.set method,// which allows to pass a context variablesetWithContext<S extends InferStateValueType<this>>(// when Counted interface is implemented by State<S> object,// I type is assignable to S. The definition of I// in this way allows to set new value to anything// what is assignable to S.newValue: SetStateAction<S>,context: {// set to true, if set call should not be countedskipCounting?: boolean}): void}// This is our extension implementation of Counted interface.// We implement all callbacks for the demonstration purposes,// but you can omit any of the callbacks you do not need to watch for.//// `extends Identifiable` for generic parameter `E` is optional depending on an extension you write.// It tells the Typescript that it can be// used only together with the Identifiable extension.//// By analogy, adding `S extends WhatEverType`, will ensure// that the extension can be added only to a State<S> where// S implements WhatEverType constraint.// In this example we allow an extension to be added to any state value type.export function countedExtension<S, E extends Identifiable>(): ExtensionFactory<S, E, Counted> {const counter = hookstate(0)let lastContext: { skipCounting?: boolean } | undefined;return () => ({onCreate: (rootState, _extensionMethods) => {// If an application uses this extension with an asynchronous state,// we need to check if rootState.promise or rootState.error is defined// before accessing the rootState.value. We skip it here for simplicity.console.log('onCreate called:', JSON.stringify(rootState.value))return {counter: (s) => {console.log('called counter for State at path:', s.path,// an example of using the other extension we required by the// `E extends Identifiable` aboves.identifier)// an example of returning data managed by an extensionreturn counter},setWithContext: (s) => (newValue, context) => {console.log('called setWithContext for State at path:', s.path, s.identifier)lastContext = context// an example of using State functions within an extensions.set(newValue)}}},// the difference with onCreate is that the onInit callback// is invoked when extension methods from all extensions are added to the stateonInit: (rootState, extensionMethods) => {console.log('onInit called:', JSON.stringify(rootState.value),// so, at this point we can use the extension methods from the dependent extensionsrootState.identifier)// an extension can inspect what other extension methods are added to the state// and opt-in to use it if necessary. For example,// `localstored` extension uses `serializable` extension if it is available// otherwise, it fallsback to JSON.stringify.console.log('onInit called: all extension methods are:', Object.keys(extensionMethods))},onPremerge: (state, value) => {console.log('onPremerge called at path:', state.path, JSON.stringify(state.value),// a value to be merged into the state valueJSON.stringify(value))},onPreset: (state, value) => {console.log('onPreset called at path:', state.path, JSON.stringify(state.value),// a value to be set into the state valueJSON.stringify(value))},onSet: (state, actionDescriptor) => {console.log('onSet called at path:', state.path, JSON.stringify(state.value),// an additional descriptor which is used to apply more// fine grained rerendering optimization.// you can use it too to find out what properties of an object// have been added, deleted or updated.JSON.stringify(actionDescriptor))// now implement the counting for our `counter` extension propertyif (!lastContext?.skipCounting) {counter.set(p => p + 1)}lastContext = undefined // reset context after set, so it does not affect next set call},onDestroy: (rootState) => {console.log('onDestroy called:', JSON.stringify(rootState.value), rootState.identifier)}})}// Now let's have a look how the extension can be usedexport const ExampleComponent = () => {const state = useHookstate([{ name: 'First Task' }],// we prepend identifiable extension// as it is required by our countedExtension,// otherwise, it will be caught by Typescript as an errorextend(identifiable('todolist'), countedExtension()));return <><CounterView state={state} />{state.map((taskState, taskIndex) =><TaskEditor key={taskIndex} taskState={taskState} />)}<button onClick={() => state.merge([{ name: 'Untitled' }])}>Add task</button></>}type Task = { name: string }// notice we require our component to have Counted extension on the propertyfunction TaskEditor(props: { taskState: State<Task, Counted> }) {return <p><inputvalue={props.taskState.name.get()}// here is an example of usage of our custom extension methodonChange={e => props.taskState.name.setWithContext(() => e.target.value, { skipCounting: false })}/></p>}// notice we require our component to have Counted extension on the propertyfunction CounterView(props: { state: State<Task[], Counted> }) {// and here were are retrieving the counter from the extension// and subscribe the component to rerender when it is changedlet counterState = useHookstate(props.state.counter);return <p>Counter value: {counterState.value}</p>}