Skip to content

A small and fashionable store for state and event management.

License

Notifications You must be signed in to change notification settings

blameitonyourisp/boutique

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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:

  1. Import package, and instantiate a new Boutique store instance, passing the initial state as an argument to the constructor
  2. Create required redactions to update state
  3. Create required listeners to respond to changes of state:
    1. Each listener responds to changes in specific parts of store state
    2. Each listener must be added to the store instance
  4. 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: "davidsmith@notgmail.com"
})

// 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 entire state.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 entire state.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:

  1. The redaction callback passed to the createRedaction method takes a proxied state object, and an optional detail object, and either has no return statement (returns void), or returns a key-value pair detail object
  2. Redaction callback function is not called upon redaction creation since this would cause an erroneous state change
  3. The createRedaction method returns the initial redaction callback wrapped in a function which may be used to repeatedly execute the same redaction
  4. The returned function may be executed with an optional key-value pair detail object, causing the redaction callback to be executed:
    1. Redaction callback directly mutates proxied state object
    2. Redaction callback optionally returns a key-value pair detail object
    3. Proxied state object updates internal state object of Boutique store instance, and triggers any listener(s) which are dependent on the updated state properties, passing the optional detail option as a second argument to each listener

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:

  1. The listener callback passed to the createListener method takes a proxied state object and an optional detail object, and returns an nullary function responsible for updating your application etc. upon state change
  2. Listener callback function is called upon listener creation:
    1. The body of the callback function before the return statement is responsible for extracting/destructuring the required properties from the proxied state object (these properties form the dependent fragment of the listener)
    2. 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
    3. The return value is ignored (i.e. the returned function is not called to avoid an erroneous application update)
  3. Listener is added to Boutique store instance using the addListener method
  4. Listener callback function is called any time the dependent fragment changes:
    1. Dependent fragment variables are extracted/destructured from updated proxied state object as in step 1.1
    2. The returned function is called with the state and optional detail variables in the closing scope
    3. 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
  5. Listener is removed from the Boutique store instance using the removeListener 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 alongside Boutique (this method can handle non-serializable properties)
  • Use JSON serialization by calling JSON.parse(JSON.stringify(state)) (note that this method will only work on state 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.