├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md └── src ├── css └── global.css ├── index.html └── js ├── components ├── count.js ├── list.js └── status.js ├── lib ├── component.js └── pubsub.js ├── main.js └── store ├── actions.js ├── index.js ├── mutations.js ├── state.js └── store.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "rules": { 8 | "no-const-assign": "warn", 9 | "no-this-before-super": "warn", 10 | "no-unreachable": "warn", 11 | "no-unused-vars": "warn", 12 | "constructor-super": "warn", 13 | "valid-typeof": "warn", 14 | "quotes": [2, "single", { "allowTemplateLiterals": true }] 15 | } 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.log 4 | npm-debug.* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andy Bell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🎉 **You can now find a packaged version of this called [Beedle](https://github.com/hankchizljaw/beedle)** 🎉 2 | 3 | *** 4 | 5 | # Vanilla JavaScript state management 6 | 7 | This is the finished product of this [CSS-Tricks tutorial](https://css-tricks.com/build-a-state-management-system-with-vanilla-javascript). It's full of code comments so you can really dig in and learn too. 8 | 9 | You can also see this in a [CodePen Project](https://codepen.io/hankchizljaw/project/editor/1f206d7807f492a111518b5d6692bb78). 10 | 11 | Any questions, [hit me up on Twitter](https://twitter.com/hankchizljaw). 12 | 13 | ## Demo 14 | 15 | ![A GIF of this project in action](https://user-images.githubusercontent.com/8672583/43128781-c58702e4-8f2a-11e8-9326-cf422a5885bd.gif) 16 | 17 | [https://vanilla-js-state-management.hankchizljaw.io](https://vanilla-js-state-management.hankchizljaw.io) 18 | -------------------------------------------------------------------------------- /src/css/global.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Define variables 3 | */ 4 | :root { 5 | --border: #888888; 6 | --decor: #777777; 7 | --text: #141414; 8 | --text-secondary: #555555; 9 | --danger: #d62c1a; 10 | --danger--dark: #a82315; 11 | } 12 | 13 | /** 14 | * Core styles 15 | */ 16 | html { 17 | height: 100%; 18 | font-size: 16px; 19 | background: #f3f3f3; 20 | } 21 | 22 | body { 23 | font-family: sans-serif; 24 | margin: 0; 25 | padding: 2rem 1rem; 26 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, 27 | Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif; 28 | font-size: 1rem; 29 | line-height: 1.5; 30 | color: var(--text); 31 | } 32 | 33 | p { 34 | margin: 0; 35 | } 36 | 37 | h1, h2 { 38 | font-weight: 400; 39 | line-height: 1.1; 40 | margin: 0; 41 | } 42 | 43 | @media all and (min-width: 40em) { 44 | main { 45 | width: 80vw; 46 | max-width: 40em; 47 | margin: 0 auto 48 | } 49 | } 50 | 51 | /** 52 | * Intro 53 | */ 54 | .intro { 55 | padding: 0 0 1rem 0; 56 | margin: 0 0 2rem 0; 57 | border-bottom: 1px dotted var(--border); 58 | } 59 | 60 | .intro__heading { 61 | font-weight: 400; 62 | } 63 | 64 | .intro__summary { 65 | margin-top: 0.3rem; 66 | font-size: 1.3rem; 67 | font-weight: 300; 68 | } 69 | 70 | .intro__summary b { 71 | font-weight: 500; 72 | } 73 | 74 | /** 75 | * App 76 | */ 77 | .app { 78 | display: grid; 79 | grid-template-columns: 1fr; 80 | grid-auto-flow: row; 81 | grid-gap: 2rem; 82 | } 83 | 84 | .app__decor { 85 | display: block; 86 | width: 100%; 87 | text-align: center; 88 | font-size: 3rem; 89 | line-height: 1; 90 | } 91 | 92 | .app__decor small { 93 | display: block; 94 | font-size: 1.3rem; 95 | font-weight: 400; 96 | color: var(--text-secondary); 97 | } 98 | 99 | .app__decor > * { 100 | display: block; 101 | } 102 | 103 | .app__decor > * + * { 104 | margin-top: 0.4rem; 105 | } 106 | 107 | .app__items { 108 | list-style: none; 109 | padding: 0; 110 | margin: 1rem 0 0 0; 111 | font-weight: 300; 112 | } 113 | 114 | .app__items li { 115 | position: relative; 116 | padding: 0 0 0 2rem; 117 | font-size: 1.3rem; 118 | } 119 | 120 | .app__items li::before { 121 | content: "🎉"; 122 | position: absolute; 123 | top: 1px; 124 | left: 0; 125 | } 126 | 127 | .app__items li + li { 128 | margin-top: 0.5rem; 129 | } 130 | 131 | .app__items button { 132 | background: transparent; 133 | border: none; 134 | position: relative; 135 | top: -1px; 136 | color: var(--danger); 137 | font-weight: 500; 138 | font-size: 1rem; 139 | margin: 0 0 0 5px; 140 | cursor: pointer; 141 | } 142 | 143 | .app__items button:hover { 144 | color: var(--danger--dark); 145 | } 146 | 147 | @media all and (min-width: 40rem) { 148 | .app { 149 | grid-template-columns: 2fr 1fr; 150 | } 151 | } 152 | 153 | 154 | /** 155 | * New item 156 | */ 157 | .new-item { 158 | margin: 2rem 0 0 0; 159 | padding: 1rem 0 0 0; 160 | border-top: 1px dotted var(--border); 161 | } 162 | 163 | /* Imporants override Boilerform styles and allow us to pull the CSS straight from the repo */ 164 | .new-item__button { 165 | position: relative; 166 | top: 2px; 167 | padding-bottom: 11px !important; 168 | } 169 | 170 | .new-item__label { 171 | font-size: 1.1rem !important; 172 | font-weight: 400 !important; 173 | } 174 | 175 | /** 176 | * No items 177 | */ 178 | .no-items { 179 | margin: 1rem 0 0 0; 180 | color: var(--text-secondary); 181 | } 182 | 183 | /** 184 | * Visually hidden 185 | */ 186 | .visually-hidden { 187 | display: block; 188 | height: 1px; 189 | width: 1px; 190 | overflow: hidden; 191 | clip: rect(1px 1px 1px 1px); 192 | clip: rect(1px, 1px, 1px, 1px); 193 | clip-path: inset(1px); 194 | white-space: nowrap; 195 | position: absolute; 196 | } 197 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Vanilla State Management 10 | 11 | 12 |
13 |
14 |

Done list

15 |

A list of things that you have achieved today.

16 |

Note: The data isn't stored, so it will disappear if you reload!

17 |
18 |
19 |
20 |

What you've done

21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | 39 |
40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/js/components/count.js: -------------------------------------------------------------------------------- 1 | import Component from '../lib/component.js'; 2 | import store from '../store/index.js'; 3 | 4 | export default class Count extends Component { 5 | constructor() { 6 | super({ 7 | store, 8 | element: document.querySelector('.js-count') 9 | }); 10 | } 11 | 12 | /** 13 | * React to state changes and render the component's HTML 14 | * 15 | * @returns {void} 16 | */ 17 | render() { 18 | let suffix = store.state.items.length !== 1 ? 's' : ''; 19 | let emoji = store.state.items.length > 0 ? '🙌' : '😢'; 20 | 21 | this.element.innerHTML = ` 22 | You've done 23 | ${store.state.items.length} 24 | thing${suffix} today ${emoji} 25 | `; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/js/components/list.js: -------------------------------------------------------------------------------- 1 | import Component from '../lib/component.js'; 2 | import store from '../store/index.js'; 3 | 4 | export default class List extends Component { 5 | 6 | // Pass our store instance and the HTML element up to the parent Component 7 | constructor() { 8 | super({ 9 | store, 10 | element: document.querySelector('.js-items') 11 | }); 12 | } 13 | 14 | /** 15 | * React to state changes and render the component's HTML 16 | * 17 | * @returns {void} 18 | */ 19 | render() { 20 | let self = this; 21 | 22 | // If there are no items to show, render a little status instead 23 | if(store.state.items.length === 0) { 24 | self.element.innerHTML = `

You've done nothing yet 😢

`; 25 | return; 26 | } 27 | 28 | // Loop the items and generate a list of elements 29 | self.element.innerHTML = ` 30 | 37 | `; 38 | 39 | // Find all the buttons in the list and when they are clicked, we dispatch a 40 | // `clearItem` action which we pass the current item's index to 41 | self.element.querySelectorAll('button').forEach((button, index) => { 42 | button.addEventListener('click', () => { 43 | store.dispatch('clearItem', { index }); 44 | }); 45 | }); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/js/components/status.js: -------------------------------------------------------------------------------- 1 | import Component from '../lib/component.js'; 2 | import store from '../store/index.js'; 3 | 4 | export default class Status extends Component { 5 | constructor() { 6 | super({ 7 | store, 8 | element: document.querySelector('.js-status') 9 | }); 10 | } 11 | 12 | /** 13 | * React to state changes and render the component's HTML 14 | * 15 | * @returns {void} 16 | */ 17 | render() { 18 | let self = this; 19 | let suffix = store.state.items.length !== 1 ? 's' : ''; 20 | 21 | self.element.innerHTML = `${store.state.items.length} item${suffix}`; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/js/lib/component.js: -------------------------------------------------------------------------------- 1 | // We're importing the store Class here so we can test against it in the constructor 2 | import Store from '../store/store.js'; 3 | 4 | export default class Component { 5 | constructor(props = {}) { 6 | let self = this; 7 | 8 | // We're setting a render function as the one set by whatever inherits this base 9 | // class or setting it to an empty by default. This is so nothing breaks if someone 10 | // forgets to set it. 11 | this.render = this.render || function() {}; 12 | 13 | // If there's a store passed in, subscribe to the state change 14 | if(props.store instanceof Store) { 15 | props.store.events.subscribe('stateChange', () => self.render()); 16 | } 17 | 18 | // Store the HTML element to attach the render to if set 19 | if(props.hasOwnProperty('element')) { 20 | this.element = props.element; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/js/lib/pubsub.js: -------------------------------------------------------------------------------- 1 | export default class PubSub { 2 | constructor() { 3 | this.events = {}; 4 | } 5 | 6 | /** 7 | * Either create a new event instance for passed `event` name 8 | * or push a new callback into the existing collection 9 | * 10 | * @param {string} event 11 | * @param {function} callback 12 | * @returns {number} A count of callbacks for this event 13 | * @memberof PubSub 14 | */ 15 | subscribe(event, callback) { 16 | 17 | let self = this; 18 | 19 | // If there's not already an event with this name set in our collection 20 | // go ahead and create a new one and set it with an empty array, so we don't 21 | // have to type check it later down-the-line 22 | if(!self.events.hasOwnProperty(event)) { 23 | self.events[event] = []; 24 | } 25 | 26 | // We know we've got an array for this event, so push our callback in there with no fuss 27 | return self.events[event].push(callback); 28 | } 29 | 30 | /** 31 | * If the passed event has callbacks attached to it, loop through each one 32 | * and call it 33 | * 34 | * @param {string} event 35 | * @param {object} [data={}] 36 | * @returns {array} The callbacks for this event, or an empty array if no event exits 37 | * @memberof PubSub 38 | */ 39 | publish(event, data = {}) { 40 | let self = this; 41 | 42 | // There's no event to publish to, so bail out 43 | if(!self.events.hasOwnProperty(event)) { 44 | return []; 45 | } 46 | 47 | // Get each subscription and call its callback with the passed data 48 | return self.events[event].map(callback => callback(data)); 49 | } 50 | } -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import store from './store/index.js'; 2 | 3 | // Load up components 4 | import Count from './components/count.js'; 5 | import List from './components/list.js'; 6 | import Status from './components/status.js'; 7 | 8 | // Load up some DOM elements 9 | const formElement = document.querySelector('.js-form'); 10 | const inputElement = document.querySelector('#new-item-field'); 11 | 12 | // Add a submit event listener to the form and prevent it from posting back 13 | formElement.addEventListener('submit', evt => { 14 | evt.preventDefault(); 15 | 16 | // Grab the text value of the textbox and trim any whitespace off it 17 | let value = inputElement.value.trim(); 18 | 19 | // If there's some content, trigger the action and clear the field, ready for the next item 20 | if(value.length) { 21 | store.dispatch('addItem', value); 22 | inputElement.value = ''; 23 | inputElement.focus(); 24 | } 25 | }); 26 | 27 | // Instantiate components 28 | const countInstance = new Count(); 29 | const listInstance = new List(); 30 | const statusInstance = new Status(); 31 | 32 | // Initial renders 33 | countInstance.render(); 34 | listInstance.render(); 35 | statusInstance.render(); 36 | -------------------------------------------------------------------------------- /src/js/store/actions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | addItem(context, payload) { 3 | context.commit('addItem', payload); 4 | }, 5 | clearItem(context, payload) { 6 | context.commit('clearItem', payload); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/js/store/index.js: -------------------------------------------------------------------------------- 1 | import actions from './actions.js'; 2 | import mutations from './mutations.js'; 3 | import state from './state.js'; 4 | import Store from './store.js'; 5 | 6 | export default new Store({ 7 | actions, 8 | mutations, 9 | state 10 | }); 11 | -------------------------------------------------------------------------------- /src/js/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | addItem(state, payload) { 3 | state.items.push(payload); 4 | 5 | return state; 6 | }, 7 | clearItem(state, payload) { 8 | state.items.splice(payload.index, 1); 9 | 10 | return state; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/js/store/state.js: -------------------------------------------------------------------------------- 1 | export default { 2 | items: [ 3 | 'I made this', 4 | 'Another thing' 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /src/js/store/store.js: -------------------------------------------------------------------------------- 1 | import PubSub from '../lib/pubsub.js'; 2 | 3 | export default class Store { 4 | constructor(params) { 5 | let self = this; 6 | 7 | // Add some default objects to hold our actions, mutations and state 8 | self.actions = {}; 9 | self.mutations = {}; 10 | self.state = {}; 11 | 12 | // A status enum to set during actions and mutations 13 | self.status = 'resting'; 14 | 15 | // Attach our PubSub module as an `events` element 16 | self.events = new PubSub(); 17 | 18 | // Look in the passed params object for actions and mutations 19 | // that might have been passed in 20 | if(params.hasOwnProperty('actions')) { 21 | self.actions = params.actions; 22 | } 23 | 24 | if(params.hasOwnProperty('mutations')) { 25 | self.mutations = params.mutations; 26 | } 27 | 28 | // Set our state to be a Proxy. We are setting the default state by 29 | // checking the params and defaulting to an empty object if no default 30 | // state is passed in 31 | self.state = new Proxy((params.state || {}), { 32 | set: function(state, key, value) { 33 | 34 | // Set the value as we would normally 35 | state[key] = value; 36 | 37 | // Trace out to the console. This will be grouped by the related action 38 | console.log(`stateChange: ${key}: ${value}`); 39 | 40 | // Publish the change event for the components that are listening 41 | self.events.publish('stateChange', self.state); 42 | 43 | // Give the user a little telling off if they set a value directly 44 | if(self.status !== 'mutation') { 45 | console.warn(`You should use a mutation to set ${key}`); 46 | } 47 | 48 | // Reset the status ready for the next operation 49 | self.status = 'resting'; 50 | 51 | return true; 52 | } 53 | }); 54 | } 55 | 56 | /** 57 | * A dispatcher for actions that looks in the actions 58 | * collection and runs the action if it can find it 59 | * 60 | * @param {string} actionKey 61 | * @param {mixed} payload 62 | * @returns {boolean} 63 | * @memberof Store 64 | */ 65 | dispatch(actionKey, payload) { 66 | 67 | let self = this; 68 | 69 | // Run a quick check to see if the action actually exists 70 | // before we try to run it 71 | if(typeof self.actions[actionKey] !== 'function') { 72 | console.error(`Action "${actionKey} doesn't exist.`); 73 | return false; 74 | } 75 | 76 | // Create a console group which will contain the logs from our Proxy etc 77 | console.groupCollapsed(`ACTION: ${actionKey}`); 78 | 79 | // Let anything that's watching the status know that we're dispatching an action 80 | self.status = 'action'; 81 | 82 | // Actually call the action and pass it the Store context and whatever payload was passed 83 | self.actions[actionKey](self, payload); 84 | 85 | // Close our console group to keep things nice and neat 86 | console.groupEnd(); 87 | 88 | return true; 89 | } 90 | 91 | /** 92 | * Look for a mutation and modify the state object 93 | * if that mutation exists by calling it 94 | * 95 | * @param {string} mutationKey 96 | * @param {mixed} payload 97 | * @returns {boolean} 98 | * @memberof Store 99 | */ 100 | commit(mutationKey, payload) { 101 | let self = this; 102 | 103 | // Run a quick check to see if this mutation actually exists 104 | // before trying to run it 105 | if(typeof self.mutations[mutationKey] !== 'function') { 106 | console.log(`Mutation "${mutationKey}" doesn't exist`); 107 | return false; 108 | } 109 | 110 | // Let anything that's watching the status know that we're mutating state 111 | self.status = 'mutation'; 112 | 113 | // Get a new version of the state by running the mutation and storing the result of it 114 | let newState = self.mutations[mutationKey](self.state, payload); 115 | 116 | // Merge the old and new together to create a new state and set it 117 | self.state = Object.assign(self.state, newState); 118 | 119 | return true; 120 | } 121 | } 122 | --------------------------------------------------------------------------------