@blameitonyourisp/boutique
v1.0.0
Published
A small and fashionable store for state and event management.
Downloads
3
Maintainers
Readme
Boutique
A small and fashionable store for event and state management implemented in under 1KB of vanilla javascript.
This repository is hosted on github, if you're already reading this there, then great! Otherwise browse the repository here.
Table of Contents
Size
Approximate download size of repository, code files within repository, compressed main file, and (just for fun) lines written by the developer including comments etc. Please note that due to file compression, and post download installs/builds such as node module dependencies, the following badges may not exactly reflect download size or space on disk.
Description
Boutique is a small and fashionable store for event and state management implemented in just 989 bytes of minified vanilla javascript. After gzip compression, boutique comes in at just 535 bytes.
Boutique is implemented using proxies. As such some legacy browsers such as internet explorer will not support boutique state and event management. Please check caniuse proxy for more information on compatibility with legacy browsers and older versions of current browsers.
Getting Started
Boutique is available as a package on npm, please see the following sections for information on getting started using this repository. Below you will find information on how to install, configure, and use the main features of the repository. See the usage and documentation sections for more detailed information on how to use all features of the repository. For more information on editing or contributing to this repository (for instance repository directory structure and npm scripts), please see the CONTRIBUTING.md
file here.
Installation
This package may be installed from npm in any appropriate javascript or typescript project. To install the package, please use the following command:
# install boutique (recommended as a normal dependency)
npm install --save @blameitonyourisp/boutique
Types for this package are written using jsdoc, are built using the typescript compiler set to emit only type declarations, and are then combined into one declaration file using rollup with the rollup-plugin-dts plugin. This declaration file is exported with the package by default, and may be found by following the types
field defined in the package.json
file.
Configuration
Boutique requires no configuration, and should be handled by any bundler you may be using to produce the production version of scripts for your site.
Basic Usage
Boutique state management functions by using proxies to detect when a specific state property changes. The state proxy intercepts the setting of a state property, updates the property manually, and executes the listeners which are recorded as being dependent on that property. Please see the following list and code block below for how to manage basic state using this package:
- Import package, and instantiate a new
Boutique
store instance, passing the initial state as an argument to the constructor - Create required redactions to update state
- Create required listeners to respond to changes of state:
- Each listener responds to changes in specific parts of store state
- Each listener must be added to the store instance
- Execute redactions when required, for instance on a button press
// Import package.
import { Boutique } from "@blameitonyourisp/boutique"
// Create a store with initial state passed to the constructor.
const store = new Boutique({ count: 0 })
// Create as many redactions as required to update state according to the
// lifecycle of your application.
const redaction = store.createRedaction(state => {
// Directly mutate or update store state.
state.count++
})
// Create as many listeners as required to respond to changes of state during
// the lifecycle of your application. Code in this callback will run every
// time the listener is called.
const listener = store.createListener(state => {
// Fetch the required state fragment(s) which this listener is dependent
// upon (the returned listener function below will be called whenever
// any extracted state value changes).
const { count } = state
// Return a nullary function which will be executed when the state
// fragments above change. Note that the variables declared above will be
// in the closing scope of the returned function below.
return () => { console.log(count) }
})
// Add listeners to store so that they are called as required upon state change.
store.addListener(listener)
// Execute redactions as required during the lifecycle of your application.
redaction()
Usage
Please see the basic usage section above for information on getting started with adding basic redactions and state change listeners. See below for more behaviours and features available when managing state with this package. Unless otherwise specified, consider that the following examples all start with this initial store instance:
// Import package.
import { Boutique } from "@blameitonyourisp/boutique"
// Initial store instance.
const store = new Boutique({
name: {
first: "David",
last: "Smith"
}
})
State
Initial state of a Boutique
store instance is passed to the Boutique
constructor, and may be any key-value object. Store state must be changed using redactions (created with the createRedaction
method on a Boutique
instance). Do not modify store state directly, since doing this will not trigger listeners. Additionally do not create circular references within your state object.
// Initial state can be any javascript object with key-value pairs.
const store = new Boutique({
name: {
first: "David",
last: "Smith"
},
age: 32,
address: {
street: "8 Moor Place",
city: "Ellesmere",
county: "Shropshire",
country: "United Kingdom",
postcode: "SY12 0AA"
},
phone: "01691 368222",
email: "[email protected]"
})
// Do *not* modify store state directly, since it will not trigger listeners.
store.state.name = {
first: "Steven",
last: "Clarke"
}
// Do *not* create circular references.
store.state.self = store.state
As a general rule, you should design your application with a 1:1 relationship between redactions and listeners in mind (i.e. where possible a given redaction should trigger a specific listener or set of listeners of a similar type). To achieve this you should:
- Design your application listeners to be granular (dependent on the smallest fragment of state as possible)
- Design your application redactions to:
- Be granular (changing the smallest part of state as possible)
- Change specific property values rather than updating entire nested state objects (e.g. change
state.address.street
, not the entirestate.address
object)
- Where listeners and redactions are less granular than this, try to match the granularity of both the listener and the redaction (i.e. if the redaction changes the
state.address
object entirely, then the listener should follow this granularity, and be dependent on the entirestate.address
object)
Maps and Sets
Unfortunately since Boutique
proxies all state properties including nested properties, JavaScript Maps and Sets cannot currently be used within the store state object since it is not possible to proxy a Map or Set without breaking it. Trying to access any method (such as has
etc.) of a proxied Map or Set within the state object will result in a <method> called on incompatible Proxy
error. For more information on this issue, please see here.
WARNING An exception for preventing proxying of Maps or Sets within the state object may be added in future versions, but at the moment the issue described above should be considered as a limitation of the state and event management provided by Boutique
. If your application currently requires reliable tracking of Map or Set objects in its state, please do not use this package.
Redactions
Redactions are the means by which you can update the state of a given Boutique
store instance within your application. Redactions mutate a proxied state
object directly, and are repeatable. The following list demonstrates the order in which a redaction functions:
- The redaction callback passed to the
createRedaction
method takes a proxiedstate
object, and an optionaldetail
object, and either has noreturn
statement (returnsvoid
), or returns a key-value pairdetail
object - Redaction callback function is not called upon redaction creation since this would cause an erroneous state change
- The
createRedaction
method returns the initial redaction callback wrapped in a function which may be used to repeatedly execute the same redaction - The returned function may be executed with an optional key-value pair
detail
object, causing the redaction callback to be executed:- Redaction callback directly mutates proxied
state
object - Redaction callback optionally returns a key-value pair
detail
object - Proxied
state
object updates internalstate
object ofBoutique
store instance, and triggers any listener(s) which are dependent on the updated state properties, passing the optionaldetail
option as a second argument to each listener
- Redaction callback directly mutates proxied
Please see the code block below for an example redaction, including comments detailing functionality:
- For more information on the behaviour of mutating state directly, please see the mutation rules section
- For more information on the optional
detail
object, please see the redaction detail section
// Create a redaction by calling the `createRedaction` method and passing a
// callback which takes the current proxied store state, and an optional
// key-value pair object passed to the redaction handler from the point of
// execution.
const redaction = store.createRedaction((state, detail) => {
// Mutate proxied state values directly.
state.name.first = "Steven"
// Optionally return some key-value pair object from the redaction handler
// to pass to the triggered redaction listener(s).
return { ...detail, value: 42 }
})
// The `createRedaction` method returns a function. To execute a redaction,
// simply call this returned function with an optional detail object to pass to
// the redaction handler.
redaction() // No detail object
redaction({ optional: "DETAIL_OBJECT" }) // With detail object
Listeners
Listeners are the means by which your application can respond to changes of state of a given Boutique
store instance. The following list demonstrates the order in which a listener functions:
- The listener callback passed to the
createListener
method takes a proxiedstate
object and an optionaldetail
object, and returns an nullary function responsible for updating your application etc. upon state change - Listener callback function is called upon listener creation:
- The body of the callback function before the
return
statement is responsible for extracting/destructuring the required properties from the proxiedstate
object (these properties form the dependent fragment of the listener) - The body of the callback function should only include variable declaration(s) extracted/destructured from the proxied
state
object, and should not include any pre-processing of dependent fragment values prior to the execution of the returned function - The return value is ignored (i.e. the returned function is not called to avoid an erroneous application update)
- The body of the callback function before the
- Listener is added to
Boutique
store instance using theaddListener
method - Listener callback function is called any time the dependent fragment changes:
- Dependent fragment variables are extracted/destructured from updated proxied
state
object as in step1.1
- The returned function is called with the
state
and optionaldetail
variables in the closing scope - The function returned from the listener callback is responsible for all updates to the DOM etc. which should be made in response to the given change of state
- Dependent fragment variables are extracted/destructured from updated proxied
- Listener is removed from the
Boutique
store instance using theremoveListener
method (or is otherwise disposed of), and no longer responds to dependent fragment changes
Please see the code block below for an example listener, including comments detailing functionality:
- For more information on responding to directly mutated state, please see the mutation rules section
- For more information on the optional
detail
object, please see the redaction detail section
// Create a redaction listener by calling the `createListener` method and
// passing a callback which takes the redacted proxied state, and an optional
// key-value pair object passed to the redaction listener form the redaction
// handler. The *entire* callback function will be called every time the
// listener is triggered.
const listener = store.createListener((state, detail) => {
// Declare the state properties which this listener will be dependent on.
// All variable extracted/destructured from the state object below will form
// the listener's "dependent state fragment". Whenever any of these
// properties (or child properties of these properties) change, the listener
// will be triggered.
const { name } = state
// Return a nullary function which will *not* be executed when the listener
// is created, but *will* be executed every time the dependent state
// fragment changes. Note that the variables declared above will be in the
// closing scope of the returned function below.
return () => { console.log(name, detail) }
})
// Call the `addListener` method to start listening for changes to the dependent
// fragment of the given listener.
store.addListener(listener)
// Call the `removeListener` method to stop listening for changes to the
// dependent fragment of the given listener.
store.removeListener(listener)
Mutation Rules
Boutique listeners will respond to state changes to any property in the dependent fragment (any property fetched or deconstructed in the listener callback before the return function), or changes to any property which are nested children of the dependent fragment. As such, changing a child property will trigger listeners dependent on the parent property, however changing a parent property will not directly trigger listeners dependent on a given child property.
Be aware that object shallow copy behaviour in javascript means that listeners may not trigger as expected if they are dependent on individual child properties of an parent object which is updated directly by a given redaction. Please see the code block below for examples:
const redaction = store.createRedaction(state => {
// Update state properties by changing entire parent object. Shallow copy
// behaviour in javascript means that the `name.first` and `name.last` will
// *not* register as having been changed, as only the reference to
// `state.name` has changed.
state.name = {
first: "Steven",
last: "Clark"
}
})
const redactionNested = store.createRedaction(state => {
// Update state properties directly.
state.name.first = "Steven"
state.name.last = "Clarke"
})
// Triggered by both `redaction` and `redactionNested`.
const listener = store.createListener(state => {
// Listener dependent on `state.name` property, and therefore will be
// triggered by both redactions above, since both redactions directly
// change the `state.name` property or one of its child properties.
const { name } = state
return () => {}
})
store.addListener(listener)
// Triggered only by `redactionNested`.
const listenerNested = store.createListener(state => {
// Listener dependent on only `state.name.first` and `state.name.last`, but
// not directly on `state.name`, therefore will not be triggered by
// `redaction` above since this redaction sets `state.name` property
// directly.
const { name: { first, last } } = state
return () => {}
})
store.addListener(listenerNested)
redaction()
redactionNested()
To avoid the potentially unexpected behaviour as illustrated above where the name.first
and name.last
properties are being changed by updating the entire name
property, and therefore not triggering the nested property listener as might be expected, please follow these rules:
- Do not update nested state properties by updating the entire parent object
- If you must update nested state properties by updating the parent property, then fetch the parent fragment in the required listener instead of fetching multiple child properties
Due to the same object shallow copy behaviour, using Object.assign
may fail to trigger listeners as expected. See the code block below for examples:
const redaction = store.createRedaction(state => {
const update = {
name: {
first: "Steven",
last: "Clarke"
}
}
// Although the state value(s) for first and last name have changed, this
// will *not* trigger the listener as expected due to shallow copy
// behaviour meaning that only the `state.name` property has been updated.
Object.assign(state, update)
})
const redactionNested = store.createRedaction(state => {
const update = {
first: "Steven",
last: "Clarke"
}
// Reference to `state.name` does *not* change in this example, instead
// `state.name.first` and `state.name.last` are both updated, and the
// listener below is triggered as expected.
Object.assign(state.name, update)
})
// Triggered only by `redactionNested`.
const listener = store.createListener(state => {
// Listener dependent on only `state.name.first` and `state.name.last`, but
// not directly on `state.name`, therefore will not be triggered by
// `redaction` above, since shallow copy behaviour when using
// `Object.assign` means that only the reference to `state.name` is
// updated.
const { name: { first, last } } = state
return () => {}
})
store.addListener(listener)
redaction()
redactionNested()
Proxies
Note that the state
object is proxied in both listener and redaction callbacks. In a redaction callback, changes to state are reflected to the internal state
object of the given Boutique
instance. Conversely, in a listener callback, the state
proxy does not reflect changes to state
to the original internal state
object of the given Boutique
instance. As such, unlike other state management systems, you should update the state
object in a redaction handler by mutating the proxied state
object *directly. Meanwhile, updating the proxied state
object in a listener has no effect on the internal state
object. See the following code block for examples:
const redaction = store.createRedaction(state => {
// The `state` should be mutated *directly* in redaction handlers.
state.name.first = "Steven"
})
const listener = store.createListener(state => {
const { name: { first, last } } = state
return () => {
// This will *not* update store's internal `state` object!
state.name.last = "Clarke"
}
})
store.addListener(listener)
redaction()
Since state
is a proxied object in both store redactions and listeners, this may result in unexpected behaviour when accessing or logging state objects. Only primitive properties of store state will be returned "as is", all non-primitive objects will be returned as a proxy when accessed from redactions or listeners.
If access to the entire original state
object is required, a clone of the store state
object may be generated. This can be achieved by creating a deep copy of the state
object by employing one of the following methods (see the code block below for examples):
- Import and use the
proxyToObject
utility method packaged alongsideBoutique
(this method can handle non-serializable properties) - Use JSON serialization by calling
JSON.parse(JSON.stringify(state))
(note that this method will only work onstate
objects which are serializable, which does not include some javascript objects such as functions or Symbols)
Both methods listed above cannot handle circular objects, and will throw an error due to too much recursion. Unfortunately, since the store state
object is proxied, using the web API structuredClone
function to create a clone of the state
object will also throw an error. See the code block below for examples:
// Import proxy clone utility function.
import { proxyToObject } from "@blameitonyourisp/boutique"
const redaction = store.createRedaction(state => {
state.name.first = "Steven"
})
const listener = store.createListener(state => {
const { name } = state
return () => {
// Note that all objects in state will be proxies when accessed in a
// listener. Only primitive properties will reflect actual store state.
console.log(name) // Proxy { <target>, <handler> }
console.log(name.first) // Steven
// You may create a shallow clone of a proxied object as follows,
// although nested objects will remain as proxies.
console.log(Object.assign({}, name)) // Object { first, last }
console.log(Object.assign({}, state)) // Object { name: Proxy }
// If required, you may create a deep clone of the proxied state using
// one of the following methods. Note that the JSON serialization
// method should be sufficient for cloning most state objects (as long
// as the state object does not contain circular or non-serializable
// properties), and will result in less bundled code since it is a
// native javascript feature. Both methods will return a *deep clone*
// of the state object, and will *not* return a reference to the
// original state object. Neither method can handle circular objects.
console.log(proxyToObject(state)) // Object { name: {...} }
console.log(JSON.parse(JSON.stringify(state))) // Object { name: {...} }
// Note that since store state object is proxied when accessed in
// listener callbacks, using the web API `structuredClone` function
// will throw an error.
console.log(structuredClone(state)) // Error
}
})
store.addListener(listener)
Since the state
object proxy handler will always return a new proxy for nested objects and for undefined properties, you can also access values which don't yet exist in a listener callback, and react when they are created:
const redaction = store.createRedaction(state => {
// Redaction creates new property which did not exist at initialisation.
state.age = 32
})
const listener = store.createListener(state => {
// Listener dependent on a value which does not yet exist on state object.
const { age } = state
return () => {
// Listener will respond to the creation of the new state property.
console.log(`${state.name.first} is now ${age}.`)
}
})
store.addListener(listener)
redaction()
Redaction Detail
An optional detail
object can be passed from the execution of a redaction to the redaction handler, and from the redaction handler to the redaction listener. Please see below for further information.
You can execute redactions with an optional detail
object. Any object passed when executing a redaction will be passed as a second argument to the redaction handler function (this is the function passed as a callback to the createRedaction
method on a Boutique
instance). This may be useful for providing context for where the action/state change originated from, or for changing the behaviour of the redaction/state change according to some value passed in the object:
const redaction = store.createRedaction((state, detail) => {
// Mutate state, and optionally do something with the detail object passed
// when the redaction is executed.
})
// The optional detail object passed when executing a redaction will be passed
// to the redaction handler function.
redaction({
source: "SOME_HTML_ELEMENT",
value: "SOME_VALUE_FETCHED_FROM_ELEMENT"
})
Finally, you can optionally return a key-value pair object from the redaction handler. Any object returned from the redaction handler will be passed as a second argument to the redaction listener function (this is the function passed as a callback to the createListener
method on a Boutique
instance). As above, this may be useful for providing context for where the action/state change originated from etc.:
const listener = store.createListener((state, detail) => {
// Fetch required state fragment as usual.
return () => {
// Do something with the important value passed in the detail object.
}
})
const redaction = store.createRedaction((state, detail) => {
// Mutate state as usual.
// Optionally return an object to pass on to the redaction listener. As a
// standard, the redaction handler should also "forward" any detail from
// the redaction execution such that all data may be passed seamlessly from
// source to handler to listener.
return { ...detail, someImportantValue: 42 }
})
CommonJS
This package also provides a commonjs export for backwards compatibility with commonjs module syntax require
imports. Please see the following code block for more information:
// Import boutique using commonjs require syntax.
const { Boutique } = require("@blameitonyourisp/boutique/COMMON_JS")
Testing
This repository uses jest for testing. See the npm scripts section of the repository CONTRIBUTING.md
file to see the scripts available for scoped test suites. Alternatively run npm test
to run all available tests.
Documentation
This repository is documented using a mixture of inline comments, jsdoc, and custom markdown tutorials for demonstrating specific functionality. For generating documentation from jsdoc comments in this repository, please see the npm scripts section of the repository CONTRIBUTING.md
file, or run npm run docs:jsdoc
. For markdown documentation files on specific functionality and features of this repository, please see the ./docs
directory.
Roadmap
If you find a bug or think there is a specific feature that should be added or changed, please file a bug report or feature request using this repository's issue tracker. Otherwise, please see below for proposed new features which may be added in later updates:
- Add some opt-in functionality to support Maps and Sets within the store state object by preventing them from being proxied, and providing extra methods to detect when a Map or Set is changed
Attributions
At the time of last update, this repository used no 3rd party assets or libraries other than those which are referenced as dependencies and/or dev dependencies in the package.json file in the root of this repository. The author will endeavour to update this section of documentation promptly as and when new 3rd party assets or libraries not referenced in the package.json file are added to this repository.
License
DISCLAIMER The author(s) of this repository are in no way legally qualified, and are not providing the end user(s) of this repository with any form of legal advice or directions.
Copyright (c) 2024 James Reid. All rights reserved.
This software is licensed under the terms of the MIT license, a copy which may be found in the LICENSE.md file in the root of this repository, or please refer to the text below. For a template copy of the license see one of the following 3rd party sites:
License Text
Copyright 2024 James Reid
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.