├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── example ├── README.md ├── index.js ├── store.js └── view.js ├── index.d.ts ├── index.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | cache: 5 | directories: 6 | - node_modules 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | ## Code of Conduct 4 | 5 | This project is intended to be a safe, welcoming space for collaboration. All contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. Thank you for being kind to each other! 6 | 7 | ## Contributions welcome! 8 | 9 | **Before spending lots of time on something, ask for feedback on your idea first!** 10 | 11 | Please search [issues](../../issues/) and [pull requests](../../pulls/) before adding something new! This helps avoid duplicating efforts and conversations. 12 | 13 | This project welcomes any kind of contribution! Here are a few suggestions: 14 | 15 | - **Ideas**: participate in an issue thread or start your own to have your voice heard. 16 | - **Writing**: contribute your expertise in an area by helping expand the included content. 17 | - **Copy editing**: fix typos, clarify language, and generally improve the quality of the content. 18 | - **Formatting**: help keep content easy to read with consistent formatting. 19 | - **Code**: help maintain and improve the project codebase. 20 | 21 | ## Code Style 22 | 23 | [![standard][standard-image]][standard-url] 24 | 25 | This repository uses [`standard`][standard-url] to maintain code style and consistency, and to avoid style arguments. 26 | 27 | [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg 28 | [standard-url]: https://github.com/feross/standard 29 | [semistandard-image]: https://cdn.rawgit.com/flet/semistandard/master/badge.svg 30 | [semistandard-url]: https://github.com/Flet/semistandard 31 | 32 | ## Project Governance 33 | 34 | **This is an [OPEN Open Source Project](http://openopensource.org/).** 35 | 36 | Individuals making significant and valuable contributions are given commit access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. 37 | 38 | ### Rules 39 | 40 | There are a few basic ground rules for collaborators: 41 | 42 | 1. **No `--force` pushes** or modifying the Git history in any way. 43 | 1. **Non-master branches** ought to be used for ongoing work. 44 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull request** to solicit feedback from other contributors. 45 | 1. Internal pull requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 46 | 1. Contributors should attempt to adhere to the prevailing code style. 47 | 48 | ### Releases 49 | 50 | Declaring formal releases remains the prerogative of the project maintainer. 51 | 52 | ### Changes to this arrangement 53 | 54 | This is an experiment and feedback is welcome! This document may also be subject to pull requests or changes by contributors where you believe you have something valuable to add or change. 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # [ISC License](https://spdx.org/licenses/ISC) 2 | 3 | Copyright (c) 2018, Nate Goldman 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # choo-store [![stability][0]][1] 2 | 3 | [![npm version][2]][3] [![build status][4]][5] 4 | [![downloads][8]][9] [![js-standard-style][10]][11] 5 | 6 | Create a store for a [`choo`](https://github.com/choojs/choo) application. 7 | 8 | [0]: https://img.shields.io/badge/stability-stable-brightgreen.svg?style=flat-square 9 | [1]: https://nodejs.org/api/documentation.html#documentation_stability_index 10 | [2]: https://img.shields.io/npm/v/choo-store.svg?style=flat-square 11 | [3]: https://npmjs.org/package/choo-store 12 | [4]: https://img.shields.io/travis/choojs/choo-store/master.svg?style=flat-square 13 | [5]: https://travis-ci.org/choojs/choo-store 14 | [8]: http://img.shields.io/npm/dm/choo-store.svg?style=flat-square 15 | [9]: https://npmjs.org/package/choo-store 16 | [10]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 17 | [11]: https://github.com/feross/standard 18 | 19 | ## Features 20 | 21 | - **namespacing**: use [`storeName`](https://github.com/choojs/choo#appusecallbackstate-emitter-app) to keep state clean and improve tracing 22 | - **scoped state**: set `initialState` to make initializing and resetting easy 23 | - **simplified events API**: organize all your `events` to reduce boilerplate 24 | - **action functions**: automagically creates `actions` that accept data and emit events 25 | - **event names in state**: event names made available in `state.events.storeName` 26 | - **free reset event**: free `reset` event included with purchase 27 | 28 | ## Install 29 | 30 | ``` 31 | npm install choo-store 32 | ``` 33 | 34 | ## Usage 35 | 36 | First, set up your store's name, initial state, and events: 37 | 38 | ```js 39 | var createStore = require('choo-store') 40 | 41 | module.exports = createStore({ 42 | storeName: 'clicks', 43 | initialState: { count: 0 }, 44 | events: { 45 | increment: ({ store, emitter }) => { 46 | store.count++ 47 | emitter.emit('render') 48 | } 49 | } 50 | }) 51 | ``` 52 | 53 | Next, register your store with your choo app: 54 | 55 | ```js 56 | var app = require('choo')() 57 | var store = require('./stores/clicks') 58 | 59 | app.use(store) 60 | ``` 61 | 62 | Now you can use store state and actions in your component: 63 | 64 | ```js 65 | var html = require('choo/html') 66 | var { actions } = require('./stores/clicks') 67 | 68 | module.exports = ({ clicks }) => { 69 | return html` 70 | 71 |

count is ${clicks.count}

72 | 73 | 74 | 75 | ` 76 | } 77 | ``` 78 | 79 | ### Example 80 | 81 | See the [`example`](./example) folder for a full working example. 82 | 83 | You can also check it out locally by cloning this repo and running `npm i && npm run example`. 84 | 85 | ## API 86 | 87 | ### `createStore({ storeName, initialState, events })` 88 | 89 | Params: 90 | 91 | - `storeName` - *string*: Name of store. Used for namespacing in state object and prefixing of event names. 92 | - `initialState` - *object*: Initial state of store. 93 | - This will be the state of the store on initialization of the app. 94 | - When calling the `reset` event, state will be returned to this value. 95 | - Must be valid, serializable JSON 96 | - `events` - *object*: List of named event functions. 97 | 98 | All params are required. 99 | 100 | Returns a regular store function (`function (state, emitter, app)`) to be supplied to Choo's `app.use()` function. 101 | 102 | Attaches event names to `state.events[storeName]` for convenience. For example, if you have a store `clicks` with an event `increment`, the event name (`clicks:increment`) will be available at `state.events.clicks.increment`. 103 | 104 | Returned function also has an `actions` property containing ready-to-go named functions that take whatever data you pass and emit the right event. 105 | 106 | ### Event Functions 107 | 108 | Event functions live in the `events` object and have the following signature: 109 | 110 | ```js 111 | function eventName ({ data, store, state, emitter, app }) {} 112 | ``` 113 | 114 | Params: 115 | 116 | - `data` - *any*: Event data supplied by user. 117 | - `store` - *object*: Local store state. 118 | - `state` - *object*: Global app state. 119 | - `emitter` - *[nanobus](https://github.com/choojs/nanobus)*: Choo event emitter. 120 | - `app` - *[choo](https://github.com/choojs/choo)*: Choo instance. 121 | 122 | Params are wrapped in a single object so that argument order is made irrelevant and users can take what they need from the event parameters object. 123 | 124 | ### Emitting Events 125 | 126 | Once a store has been created, these three methods of emitting an event all do the same thing: 127 | 128 | ```js 129 | store.actions.increment(1) 130 | emit(state.events.clicks.increment, 1) 131 | emit('clicks:increment', 1) 132 | ``` 133 | 134 | ### Global Events 135 | 136 | You can listen for any of Choo's global events (`DOMContentLoaded`, `DOMTitleChange`, 137 | `navigate`, `popState`, `pushState`, `render`, `replaceState`) by adding an event 138 | with the appropriate name to the `events` object: 139 | 140 | ```js 141 | createStore({ 142 | storeName: 'history', 143 | initialState: { navigations: 0 }, 144 | events: { 145 | navigate: ({ store, emitter }) => { 146 | store.navigations++ 147 | emitter.emit('render') 148 | } 149 | } 150 | }) 151 | ``` 152 | 153 | > Note: global events are not added to `state.events[storeName]` and do not have 154 | an action function associated with them since they are not namespaced events. 155 | 156 | ### `reset` event 157 | 158 | A `reset` event (e.g. `storeName:reset`) is added by default. 159 | 160 | Emitting this event will reset the store's state to `initialState`. 161 | 162 | It takes a `render` boolean option in case you want to emit a render event afterwards. 163 | 164 | ```js 165 | store.actions.reset({ render: true }) 166 | ``` 167 | 168 | ## Why 169 | 170 | **Q: Choo has a decent way to create a store already. Why use this?** 171 | 172 | **A: Bigger apps need more structure!** 173 | 174 | As an application gets larger, some issues can arise that need to be dealt with: 175 | 176 | - properly namespacing stores and events 177 | - resetting stores to their initial state 178 | - avoiding direct manipulation of other stores 179 | - providing coherent structure for a project 180 | - reducing repetitive boilerplate 181 | 182 | Doing the above gets time consuming the bigger an app gets. Without lots of attention to detail, it's easy to lose track of [value drift](https://universalpaperclips.gamepedia.com/Value_Drift) between stores in these cases. This module aims to make the process of managing stores and events simple and easy. 183 | 184 | ## Contributing 185 | 186 | Contributions welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before getting started. 187 | 188 | ## License 189 | 190 | [ISC](LICENSE.md) 191 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Choo Store Example 2 | 3 | The store is set up in `store.js`, registered to a `choo` application in `index.js`, and used in a component in `view.js`. 4 | 5 | Run it locally with `npm run example`. 6 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | var devtools = require('choo-devtools') 2 | var store = require('./store') 3 | var view = require('./view') 4 | var app = require('choo')() 5 | 6 | app.route('/', view) 7 | app.use(devtools()) 8 | app.use(store) 9 | app.mount('body') 10 | -------------------------------------------------------------------------------- /example/store.js: -------------------------------------------------------------------------------- 1 | var createStore = require('../') 2 | 3 | module.exports = createStore({ 4 | storeName: 'clicks', 5 | initialState: { count: 0 }, 6 | events: { 7 | increment: ({ data, store, emitter }) => { 8 | store.count += data 9 | emitter.emit('render') 10 | } 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /example/view.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | var { actions } = require('./store') 3 | 4 | module.exports = (state, emit) => { 5 | return html` 6 | 7 |

count is ${state.clicks.count}

8 | 9 | 10 | 11 | ` 12 | } 13 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as EventEmitter from 'events' 4 | import Choo from 'choo' 5 | 6 | type IEmitter = { 7 | data: any, 8 | store: string, 9 | emitter: EventEmitter, 10 | state: Choo.IState, 11 | app: Choo 12 | } 13 | 14 | declare namespace ChooStore { 15 | export interface InitialState { 16 | [key:string]: any 17 | } 18 | 19 | export interface Events { 20 | [key:string]: IEmitter 21 | } 22 | 23 | export function Emitter(args:IEmitter): void 24 | 25 | export interface IChooStore { 26 | storeName: string 27 | initialState: InitialState, 28 | events: Events 29 | } 30 | } 31 | 32 | declare function ChooStore (store:ChooStore.IChooStore): (state:Choo.IState, emitter: EventEmitter, app: Choo) => void 33 | 34 | export = ChooStore -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var globals = [ 2 | 'DOMContentLoaded', 3 | 'DOMTitleChange', 4 | 'navigate', 5 | 'popState', 6 | 'pushState', 7 | 'render', 8 | 'replaceState' 9 | ] 10 | 11 | /** 12 | * Create a new store. 13 | * 14 | * @param {object} options - store options 15 | * @param {string} options[].storeName - name of store - used for namespacing & debugging 16 | * @param {object} options[].initialState - initial state of store - used for init & reset 17 | * @param {object} options[].events - event functions 18 | * @return {function} - choo middleware function 19 | */ 20 | function createStore (options) { 21 | var { storeName, initialState, events } = options || {} 22 | 23 | if (typeof storeName !== 'string') throw new Error('storeName required') 24 | if (typeof initialState !== 'object') throw new Error('initialState required') 25 | if (typeof events !== 'object') throw new Error('events required') 26 | 27 | var props = Object.assign({}, options, { actions: {} }) 28 | 29 | // API ref: https://github.com/choojs/choo#appusecallbackstate-emitter-app 30 | function store (state, emitter, app) { 31 | state[storeName] = deepClone(initialState) 32 | state.events = state.events || {} // be defensive 33 | state.events[storeName] = {} 34 | 35 | // add reset event if undefined 36 | if (!props.events.reset) { 37 | props.events.reset = ({ data, store, emitter }) => { 38 | var { render } = data || {} 39 | state[storeName] = deepClone(initialState) 40 | if (render) emitter.emit('render') 41 | } 42 | } 43 | 44 | Object.keys(events).forEach(event => { 45 | var eventName = globals.includes(event) ? event : `${storeName}:${event}` 46 | 47 | // attach events to emitter 48 | emitter.on(eventName, data => { 49 | events[event]({ data, store: state[storeName], emitter, state, app }) 50 | }) 51 | 52 | // don't create namespaced event hooks for global events 53 | if (!globals.includes(event)) { 54 | // add event names to state.events 55 | state.events[storeName][event] = eventName 56 | 57 | // add action method 58 | props.actions[event] = data => emitter.emit(eventName, data) 59 | } 60 | }) 61 | } 62 | 63 | Object.assign(store, props) 64 | 65 | return store 66 | } 67 | 68 | function deepClone (obj) { 69 | return JSON.parse(JSON.stringify(obj)) 70 | } 71 | 72 | module.exports = createStore 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "choo-store", 3 | "description": "Create a store for a choo application.", 4 | "version": "1.1.2", 5 | "author": "Nate Goldman ", 6 | "bugs": { 7 | "url": "https://github.com/ungoldman/choo-store/issues" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^10.12.18", 11 | "budo": "^11.2.0", 12 | "choo": "^6.0.0", 13 | "choo-devtools": "^2.5.0", 14 | "nanobus": "^4.3.3", 15 | "nyc": "^12.0.2", 16 | "standard": "^11.0.1", 17 | "tape": "^4.9.1" 18 | }, 19 | "files": [ 20 | "index.js", 21 | "index.d.ts" 22 | ], 23 | "homepage": "https://github.com/ungoldman/choo-store", 24 | "keywords": [ 25 | "choo", 26 | "store" 27 | ], 28 | "license": "ISC", 29 | "main": "index.js", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/ungoldman/choo-store.git" 33 | }, 34 | "scripts": { 35 | "example": "budo example --open", 36 | "test": "standard && nyc tape test.js" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var choo = require('choo') 3 | var html = require('choo/html') 4 | var nanobus = require('nanobus') 5 | var createStore = require('./') 6 | 7 | test('errors', t => { 8 | t.throws(() => createStore(), /storeName required/, 'throws on empty call') 9 | t.throws(() => createStore({}), /storeName required/, 'throws on missing storeName') 10 | t.throws(() => createStore({ storeName: 'foo' }), /initialState required/, 'throws on missing initialState') 11 | t.throws(() => createStore({ storeName: 'foo', initialState: {} }), /events required/, 'throws on missing events') 12 | t.end() 13 | }) 14 | 15 | test('storeName', t => { 16 | var storeName = 'demo' 17 | var store = createStore({ storeName, initialState: {}, events: {} }) 18 | t.equals(store.storeName, storeName, 'has storeName prop') 19 | t.end() 20 | }) 21 | 22 | test('integration', t => { 23 | var app, objStore, arrStore, resetStore, globalStore 24 | 25 | t.test('instantiation', t => { 26 | t.doesNotThrow(() => { 27 | objStore = createStore({ 28 | storeName: 'obj', 29 | initialState: { a: 1 }, 30 | events: { 31 | increment: function ({ store }) { 32 | store.a++ 33 | } 34 | } 35 | }) 36 | }, 'create object store') 37 | 38 | t.doesNotThrow(() => { 39 | arrStore = createStore({ 40 | storeName: 'arr', 41 | initialState: [1], 42 | events: { 43 | increment: function ({ data, store }) { 44 | store[0]++ 45 | } 46 | } 47 | }) 48 | }, 'create array store') 49 | 50 | t.doesNotThrow(() => { 51 | resetStore = createStore({ 52 | storeName: 'reset', 53 | initialState: { a: 3 }, 54 | events: { 55 | reset: function ({ store }) { 56 | store.a = store.a * store.a 57 | } 58 | } 59 | }) 60 | }, 'create reset store') 61 | 62 | t.doesNotThrow(() => { 63 | globalStore = createStore({ 64 | storeName: 'global', 65 | initialState: { navigations: 0 }, 66 | events: { 67 | navigate: function ({ store }) { 68 | store.navigations++ 69 | } 70 | } 71 | }) 72 | }, 'create global (navigation) store') 73 | 74 | t.end() 75 | }) 76 | 77 | t.test('registration', t => { 78 | t.doesNotThrow(() => { 79 | app = choo() 80 | app.use(objStore) 81 | app.use(arrStore) 82 | app.use(resetStore) 83 | app.use(globalStore) 84 | app.route('/', (state, emit) => html``) 85 | app.toString('/') 86 | }, 'stores register') 87 | 88 | t.end() 89 | }) 90 | 91 | t.test('initialState', t => { 92 | t.equals(app.state.obj.a, 1, 'objStore state is good') 93 | t.equals(app.state.arr[0], 1, 'arrStore state is good') 94 | t.equals(app.state.reset.a, 3, 'resetStore state is good') 95 | t.equals(app.state.global.navigations, 0, 'globalStore state is good') 96 | t.end() 97 | }) 98 | 99 | t.test('events/actions', t => { 100 | t.doesNotThrow(objStore.actions.increment, 'object increment works') 101 | t.doesNotThrow(arrStore.actions.increment, 'array increment works') 102 | 103 | t.equals(app.state.obj.a, 2, 'arrStore state updated') 104 | t.equals(app.state.arr[0], 2, 'arrStore state updated') 105 | 106 | t.end() 107 | }) 108 | 109 | t.test('global navigation', t => { 110 | t.notOk(globalStore.actions.navigate, 'global event not namespaced') 111 | 112 | app.emit(app.state.events.NAVIGATE) 113 | 114 | t.equals(app.state.global.navigations, 1, 'globalStore state updated') 115 | 116 | t.end() 117 | }) 118 | 119 | t.test('reset', t => { 120 | t.doesNotThrow(objStore.actions.reset, 'object reset works') 121 | t.doesNotThrow(arrStore.actions.reset, 'array reset works') 122 | t.doesNotThrow(resetStore.actions.reset, 'custom reset works') 123 | 124 | t.equals(app.state.obj.a, 1, 'object reset state is good') 125 | t.equals(app.state.arr[0], 1, 'array reset state is good') 126 | t.equals(app.state.reset.a, 9, 'custom reset state is good') 127 | 128 | t.end() 129 | }) 130 | 131 | t.test('reset with render', t => { 132 | t.doesNotThrow(() => objStore.actions.reset({ render: true }), 'object reset render works') 133 | t.doesNotThrow(() => arrStore.actions.reset({ render: true }), 'array reset render works') 134 | t.doesNotThrow(() => resetStore.actions.reset({ render: true }), 'custom reset render works') 135 | 136 | t.equals(app.state.obj.a, 1, 'object reset state is good') 137 | t.equals(app.state.arr[0], 1, 'array reset state is good') 138 | t.equals(app.state.reset.a, 81, 'custom reset state is good') 139 | 140 | t.end() 141 | }) 142 | 143 | t.test('does not throw on missing state.events', t => { 144 | var store = createStore({ 145 | storeName: 'nope', 146 | initialState: { a: 1 }, 147 | events: { increment: ({ store }) => (store.a++) } 148 | }) 149 | 150 | t.doesNotThrow(() => store({}, nanobus()), 'works fine if state has no events object') 151 | 152 | t.end() 153 | }) 154 | 155 | t.end() 156 | }) 157 | --------------------------------------------------------------------------------