Getting Started
Forest is an open source module in NPM. It can be imported from @wonderlandlabs/forest
.
The source code is here in GitHub (opens in a new tab).
Once installed, follow these steps to embed Forest in your component.
NOTE: there are two "tiers" of state - local state, created with and relative to a specific component, and global state, which is shared across the application. We'll start with a local example, a basic login form.
The Use Case: local login form
First, we'll create the shell that we will inject our state into. (we will define the state in the next state)
import { useEffect, useState } from 'react'
import loginState from './loginState'
import style from './LoginForm.module.scss'
const LoginForm = () => {
const [formState, setFormState] = useState({})
/*
formState will have the following structure:
{
username: {fieldValue: string},
password: {fieldValue: string},
disabled: boolean
}
*/
const [forest, setForest] = useState({}) // a Forest instance
useEffect(() => {
const forest = loginState()
setForest(forest)
// express state changes using an Observer pattern to our local hooks.
const sub = forest.subscribe((value) => {
console.log('forest value is now ', value)
setFormState(value)
})
// see RxJS observable pattern for notes on observers
return () => sub.unsubscribe()
}, []) // executed once on first init.
if (!(forest && forest.do)) {
return ''
} // if for some reason the forest is not created wait for it to be, and render nothing
return (
<section className={style['login-form']}>
<form>
<div className={style['form-row']}>
<label>Username</label>
<input type="text" value={formState.username.fieldValue}
autoComplete="off"
data-lpignore="true"
onChange={forest.child('username').do.update}/>
</div>
<div className={style['form-row']}>
<label>password</label>
<input type="password" value={formState.password.fieldValue}
autoComplete="off"
data-lpignore="true"
onChange={forest.child('password').do.update}/>
</div>
<div className={[style['form-row'], style['form-button-row']].join(' ')}>
{/* shorthand for `forest.do.submit` */}
<button disabled={formState.disabled} onClick={forest.do.submit} type="submit">Log In</button>
</div>
</form>
</section>
)
}
export default LoginForm
Note in this case we will be obliged to create two children in our state system, 'username' and 'password'.
The loginState utility
we already have a structural mandate for how our forest's data is arranged from the consuming form.
Here is where we make that:
import { Forest } from '@wonderlandlabs/forest'
/*
formState will have the following structure:
{
username: {fieldValue: string, error: string},
password: {fieldValue: string, error: string},
disabled: boolean
}
*/
export default () => {
/**
* this is a "fragment" that defines a configuration for any field child
*/
const field = () => ({
$value: { fieldValue: '', error: '' },
actions: {
update: (leaf, e) => {
// the first property, leaf, is a self-refence to the context;
// the root store in the forest, and each of it's children, are instances
// of the Leaf class.
leaf.do.set_fieldValue(e.target.value)
// setters are autogenerated for each key field in the value
// if the value is an Object or Map
// in the form `set_{keyname}`
}
}
})
return new Forest({
$value: { disabled: false },
children: {
username: field(),
password: field()
},
actions: {
submit: (leaf) => {
console.log('sending ', leaf.value, 'to the server')
const data = {
username: leaf.value.username.fieldValue,
password: leaf.value.password.fieldValue
}
leaf.do.set_disabled(true)
//@TODO: post data to a server
}
}
})
}
Note how easy it is to create reusable child definitions with factory functions.
The Component in practice:
Here is the above component, running live:
A quick guide to the class interfaces for Forest
Forest is a functional term for the "root Leaf. It, and all its Children, are instances of the Leaf class.
Leaf
Constructor
{
$value: any -- the value of the root leaf; required
children? {
[childName]: childConfig -- the same format as the Forest config - recursive
....
},
actions?: {
[actionName]: (leaf, ...other args) => {...},
...
}
filter?: (nextValue) => filteredValue -- does any intial "Cleanup" of next value, prior to type/test validation
types?: 'string' | 'number' | 'object' | 'array' | true | (array of type strings) -- limits which type the value can be;
-- if it is true (boolean), detects the type of $value and restricts updates to tha same type
tests?: (value) => error | falsy, or an array of tests
}
Properties:
- value: the current value of the leaf; settable.
- do: an object with all the leaf's actions. If the leaf's initial value is an object will also have
set_{fieldName}(fieldValue)
actions for updating single fields in the leaf's value - forest: each Leaf generated by a Forest has a reference back to the forest attached to it
- children: an array of metadata:
{child: Leaf, key: string; leafId: string }
- parent: Leaf | undefined. In a child leaf, points to the direct parent.
Methods:
subscribe((nextValue) => {...})
an interface to get updates when the root leaf's value is committedchild(name: string)
: Leaf (or undefined) returns the child leaf that has responsibility for a specific key (if one exists).
Actions
Forest actions can take arguments when called. however, the function as written in the configuration accepts the leaf instance as the first argument, automatically. As a corollary, there is no binding or defined meaning to "this" inside a Leaf action.
Children
A Leaf's children take responsibility for keyed values in a Leaf -- provided the leaf is a Map or Object.
It can have .do
actions, like the root leaf, that take a reference to the child leaf as the first argument.
Currently, child structures in Forest are intended to be attached at fixed points on creation; dynamic children. For instance, Adding a child inside an action will not be reverted if a transaction fails. will be available in future releases.
Adding behavior and feedback to LoginForm2
The above is a relatively succinct code example but it can be made more interesting. Lets not only create the form, but vary its behavior based on the input and add a "mock backend" that validates the form.
In this example there is only one user, 'Test User', whose password is 'password'.
import { Forest } from '@wonderlandlabs/forest'
/*
formState will have the following structure:
{
username: {fieldValue: string, error: string},
password: {fieldValue: string, error: string},
disabled: boolean
}
*/
export default () => {
/**
* this is a "fragment" that defines a configuration for any field child
*/
const field = (inputTest) => ({
$value: { fieldValue: '', feedback: '' },
actions: {
testInput: (leaf) => {
const feedback = inputTest(leaf.fieldValue);
leaf.do.set_feedback(feedback);
},
update: (leaf, e) => {
leaf.do.set_fieldValue(e.target.value)
leaf.do.setFeedback('') // resetting feedback (if any) from failed submissions
}
}
})
return new Forest({
$value: { disabled: false, feedback: '', loggedIn: false },
children: {
username: field((input) => input ? '' : 'Required'),
password: field((input) => input ? '' : 'Required')
},
actions: {
canSubmit(leaf) {
return (!leaf.value.username.feedback) && (!leaf.value.password.feedback);
},
success: (leaf) => {
leaf.do.set_loggedIn(true)
leaf.do.set_feedback('You have successfully logged in ')
},
failure: (leaf) => {
leaf.do.set_loggedIn(false)
leaf.do.set_feedback('Your username and password are not correct')
},
submit: (leaf) => {
leaf.child('username').do.testInput()
leaf.child('password').do.testInput()
if (leaf.do.canSubmit()) {
leaf.do.set_disabled(true);
const data = {
username: leaf.value.username.fieldValue,
password: leaf.value.password.fieldValue
}
setTimeout(() => {
leaf.do.set_disabled(false);
if (data.username === 'Test User' && data.password === 'password') {
leaf.do.success()
} else {
leaf.do.failure()
}
}, 2000)
}
}
}
})
}
We have added validators to the field factory, and value tests to the submit action. Note that the submit function is synchronous, but it has a delayed (timeout) response to simulate the receipt of a submitted block of data. The success or failure of the user keys are indicated by a message field at the bottom of the field.
Additionally, there are pre-submit tests for each field -- simple ones at this point that fail when the fields are empty.
This version of the store has the following triggers:
- on entry in a field, its feedback is reset.
- on submit, each child is checked for errors
- if there are field errors the submit stops short
- If there are no field errors the form entry is temporarily disabled,
and a few seconds later, the submitted values are tested against the (one) good user - the loggedIn flag is set to true (or false) based the correct user values are submitted
- the form feedback message is adjusted to reflect success or failure
Here is the updated LoginForm2 component; nearly identical with a few more places for feedback.
import { useEffect, useState } from 'react'
import loginState from './loginState2'
import style from './LoginForm.module.scss'
const LoginForm2 = () => {
const [formState, setFormState] = useState({})
/*
formState will have the following structure:
{
username: {fieldValue: string, feedback: string},
password: {fieldValue: string, feedback: string},
sending: boolean
loggedIn: boolean
feedback: string
}
*/
const [forest, setForest] = useState({})
useEffect(() => {
const forest = loginState()
setForest(forest)
const sub = forest.subscribe((value) => {
console.log('forest value is now ', value)
setFormState(value)
})
return () => sub.unsubscribe()
}, [])
if (!(forest && forest.do)) {
return ''
}
return (
<section className={style['login-form']}>
<form> {/* shorthand for `forest.do.submit` */}
<div className={style['form-row']}>
<label>Username {
!formState.username.feedback ? '' : (
<span className={style['feedback']}>
{formState.username.feedback}
</span>
)
}</label>
<input type="text" value={formState.username.fieldValue}
autoComplete="off"
data-lpignore="true"
disabled={formState.sending}
onChange={forest.child('username').do.update}/>
</div>
<div className={style['form-row']}>
<label>Password {
!formState.password.feedback ? '' : (
<span className={style['feedback']}>
{formState.password.feedback}
</span>
)
}</label>
<input type="password" value={formState.password.fieldValue}
autoComplete="off"
data-lpignore="true"
disabled={formState.sending}
onChange={forest.child('password').do.update}/>
</div>
<div className={[style['form-row'], style['form-button-row']].join(' ')}>
<button disabled={formState.sending} onClick={forest.do.submit} type="submit">Log In</button>
</div>
{
!formState.feedback ? '' : (
<p className={[style['feedback'], formState.loggedIn ? style['feedback__success'] : ''].join(' ')}>
{formState.feedback}
</p>
)
}
{
!formState.sending ? '' : (
<p className={style['sending']}>Sending form data</p>
)
}
</form>
</section>
)
}
export default LoginForm2