A tiny, stream-free* riff on @foxdonut's brilliant and elegant Meiosis pattern.
* That's right, no streams were harmed in the making of this package. But of course you can bring some of your own if you want.
I love Meiosis. I also love a nice godref. So here we are: AIMS uses the kernel of the Meiosis pattern, shallowly, to create both infrastructure and methodology for managing application state, without requiring users to be self-loathing or good at wrestling*. Oh and it's also just over 750 bytes with zero dependencies.
* Meiosis doesn't have these requirements either, but many other state management approaches do. You know who you are.
npm i aims-js
These are passed at instantiation to aims
:
type | description | default | |
---|---|---|---|
a |
function | Accumulator: (x, y) => ({}) |
merge * |
i |
object | Initial state object | {} |
m |
function or array | Mutators/Measurements: (state, patch?) => ({}) (or an array of these) |
[] |
s |
boolean | Safemode | false |
* merge
is a slightly modified port of mergerino
by @fuzetsu.
These are attached to the returned aims
instance:
usage | description | |
---|---|---|
get |
const foo = state.get() |
returns the current state |
patch * ** |
state.patch({ bar: 'baz' }) |
uses the a function to apply the passed-in patch,which in turn generates a whole new state |
* In AIMS parlance, the word "patch" has dual meanings: as a verb, it's the method we use to "patch" our state with new values; as a noun, it's the object which provides those values. Try not to use both in the same sentence :) "Patrick, please patch our state with this patch."
** In safemode
, patch
is not a property of state
, and
instead is passed as the second argument to m
.
Begin here:
import aims from 'aims-js'
const state = aims()
Now state
is ready to use. Give it some properties:
state.patch({
name: 'Jack',
height: 'Short'
})
Ok, now let's access our state:
const { name, height } = state.get()
console.log(name, height) // Jack Short
Any function with the signature (previous_state, incoming_patch) => ({})
(e.g. Object.assign
) will do:
// low-rent, shallow immutability
const state = aims({
a: (prev, incoming) => Object.assign({}, prev, incoming)
})
Of course, in our first example, we could've set name, height
at initialization:
const i = {
name: 'Mike',
height: 'Average'
}
const state = aims({ i })
const { name, height } = state.get()
console.log(name, height) // Mike Average
Mutators are easier to illustrate than to explain:
const m = state => ({
// MUTATION FUNCTIONS:
// apply patches to state
setFirstName: firstName => {
state.patch({ firstName })
},
setLastName: lastName => {
state.patch({ lastName })
},
// MEASUREMENT FUNCTIONS:
// side effects, computations, and whatever
// else you want to be able to access via
// `state.myMeasurement(...)`
fullName: () => {
const { firstName, lastName } = state.get()
return `${firstName} ${{lastName}}`
},
})
const state = aims({ m: mutators })
/* ...somwhere in your code... */
onclick: e => { state.setFoo(e.target.textContent) }
Each mutator is a closure which accepts state
as its parameter,
and returns an object with state
in scope. aims
attaches the
properties of each returned object to state
, so calls can
be made via state.myMethod(...)
.
You may have multiple, discrete sets of mutators, e.g.
SocketMutators
and RESTMutators
.
const state = aims({ m: [SocketMutators, RESTMutators] })
In this case, it may be advisable to set namespaces, since aims
is
determinedly tiny and won't detect collisions for you.
// create the "Socket" namespace
const SocketMutators = state => ({
Socket: {
setFoo: foo => {
state.patch({ foo })
}
}
})
// ...and the "REST" namespace
const RESTMutators = state => ({
REST: {...}
})
const state = aims({m: [SocketMutators, RESTMutators]})
// destructure state — NOT state.get()
const { Socket } = state
Socket.setFoo('jack')
console.log(state.get()) // { foo: 'jack' }
In larger codebases, it may be desirable to restrict mutations to actions
only, eliminating occurences of state.patch({...})
within application
views and elsewhere. Safemode achieves this by omitting state.patch
and
instead passing the patching function as a second parameter to Mutators, e.g.
const state = aims({
m: (state, patch) => ({
setFoo: foo => {
patch({ foo })
}
})
s: true,
})
Sometimes there are imperatives associated with particular state changes.
TodoMVC is a great example — every data change must be persisted, as must
every filter
change, which must change the URL for routing purposes.
Rather than having several mutators each kicking off persistence and
routing, we can use a custom accumulator to inspect incoming patches and
respond accordingly, all in one place. A Mithril
implementation might look like this:
import aims from 'aims-js'
const a = (prev, incoming) => {
// update the route on filter changes
if (incoming.filter) m.route.set('/' + incoming.filter)
// update localStorage with new state
const new_state = Object.assign(prev, incoming)
localStorage.setItem('todoapp-aims-m', JSON.stringify(new_state))
return new_state
}
const state = aims({ a })
* Unless you're using an auto-redrawing library or framework like Mithril.js, in which case you can skip this step.
To render your view, pass a function to the second argument of aims
, which
in turn takes state
as its own argument, and render your App within the
function. So for e.g. React:
import { createRoot } from 'ReactDOM'
const root = createRoot(document.getElementById('app'))
// Here the reference returned from `aims` is
// unneeded, since we pass `state` to the function
// provided, so we can just call `aims` directly
aims({}, state => {
root.render(<App state={state} />)
})