├── .gitignore ├── .editorconfig ├── tsconfig.json ├── rollup.config.js ├── package.json ├── src └── connect.ts └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "es2015", 5 | "target": "es2015", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "lib": [ 9 | "es2017", 10 | "dom" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import pkg from './package.json'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import typescript from 'rollup-plugin-typescript'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | 8 | export default [{ 9 | input: 'src/connect.ts', 10 | output: { 11 | file: 'dist/' + pkg.main, 12 | format: 'cjs', 13 | name: 'connect', 14 | }, 15 | plugins: [ 16 | resolve(), 17 | typescript({ 18 | typescript: require('typescript'), 19 | }), 20 | ], 21 | }, { 22 | input: 'src/connect.ts', 23 | output: { 24 | file: 'dist/' + pkg.browser, 25 | format: 'iife', 26 | name: 'connect', 27 | }, 28 | plugins: [ 29 | resolve(), 30 | typescript({ 31 | typescript: require('typescript'), 32 | }), 33 | terser(), 34 | ], 35 | }] 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@captaincodeman/redux-connect-element", 3 | "version": "2.0.0", 4 | "description": "Redux HTMLElement Connector", 5 | "main": "connect.cjs", 6 | "browser": "connect.min.js", 7 | "module": "connect.js", 8 | "types": "connect.d.ts", 9 | "type": "module", 10 | "unpkg": "connect.min.js", 11 | "scripts": { 12 | "build": "npm run build:es && npm run build:js", 13 | "build:es": "tsc --module es2015 --declaration", 14 | "build:js": "rollup -c", 15 | "dev": "rollup -c -w", 16 | "mypublish:pre": "npm run build && cp readme.md package.json dist", 17 | "mypublish": "npm run mypublish:pre && npm publish dist --tag latest --access=public" 18 | }, 19 | "author": "simon@captaincodeman.com", 20 | "license": "ISC", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/captaincodeman/redux-connect-element" 24 | }, 25 | "peerDependencies": { 26 | "redux": "^4.0.0" 27 | }, 28 | "devDependencies": { 29 | "rollup": "^1.27.14", 30 | "rollup-plugin-node-resolve": "^5.2.0", 31 | "rollup-plugin-terser": "^5.1.3", 32 | "rollup-plugin-typescript": "^1.0.1", 33 | "tslib": "^1.10.0", 34 | "typescript": "^3.7.4" 35 | }, 36 | "dependencies": { 37 | "redux": "^4.0.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/connect.ts: -------------------------------------------------------------------------------- 1 | import { Unsubscribe, Store, Action } from 'redux'; 2 | 3 | export type DispatchMap = { [key: string]: (event: Event) => void } 4 | 5 | export interface ConnectProps { 6 | mapState?(state: any): { [key: string]: any } 7 | } 8 | 9 | export interface ConnectEvents { 10 | mapEvents?(): { [key: string]: (event: Event) => Action } 11 | } 12 | 13 | export interface Connectable extends HTMLElement, ConnectProps, ConnectEvents { 14 | connectedCallback?(): void 15 | disconnectedCallback?(): void 16 | } 17 | 18 | export type Constructor = new (...args: any[]) => T 19 | 20 | const unsubscribe: unique symbol = Symbol() 21 | const dispatchMap: unique symbol = Symbol() 22 | const createDispatchMap: unique symbol = Symbol() 23 | const addEventListeners: unique symbol = Symbol() 24 | const removeEventListeners: unique symbol = Symbol() 25 | const addStateSubscription: unique symbol = Symbol() 26 | const removeStateSubscription: unique symbol = Symbol() 27 | const onStateChange: unique symbol = Symbol() 28 | 29 | export function connect>( 30 | store: Store, 31 | superclass: T 32 | ) { 33 | class connected extends superclass { 34 | private [unsubscribe]: Unsubscribe; 35 | private [dispatchMap]: DispatchMap 36 | 37 | constructor(...args: any[]) { 38 | super(...args) 39 | this[createDispatchMap]() 40 | } 41 | 42 | connectedCallback() { 43 | if (super.connectedCallback) { 44 | super.connectedCallback() 45 | } 46 | 47 | this[addEventListeners]() 48 | this[addStateSubscription]() 49 | } 50 | 51 | disconnectedCallback() { 52 | this[removeStateSubscription]() 53 | this[removeEventListeners]() 54 | 55 | if (super.disconnectedCallback) { 56 | super.disconnectedCallback() 57 | } 58 | } 59 | 60 | private [createDispatchMap]() { 61 | this[dispatchMap] = {} 62 | if (this.mapEvents) { 63 | const eventMap = this.mapEvents() 64 | for (const key in eventMap) { 65 | this[dispatchMap][key] = (event: Event) => store.dispatch(eventMap[key](event)) 66 | } 67 | } 68 | } 69 | 70 | private [addEventListeners]() { 71 | for (const key in this[dispatchMap]) { 72 | this.addEventListener(key, this[dispatchMap][key], false) 73 | } 74 | } 75 | 76 | private [removeEventListeners]() { 77 | for (const key in this[dispatchMap]) { 78 | this.removeEventListener(key, this[dispatchMap][key], false) 79 | } 80 | } 81 | 82 | private [addStateSubscription]() { 83 | this[unsubscribe] = store.subscribe(this[onStateChange].bind(this)) 84 | this[onStateChange]() 85 | } 86 | 87 | private [removeStateSubscription]() { 88 | this[unsubscribe] && this[unsubscribe]() 89 | this[unsubscribe] = null 90 | } 91 | 92 | private [onStateChange]() { 93 | this.mapState && Object.assign(this, this.mapState(store.getState())) 94 | } 95 | } 96 | 97 | return connected as Constructor & T 98 | } 99 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # redux-connect-element 2 | 3 | Connect Redux to vanilla HTMLElement (or LitElement) instances, based on this 4 | [gist by Kevin Schaaf](https://gist.github.com/kevinpschaaf/995c9d1fd0f58fe021b174c4238b38c3). 5 | 6 | Typescript friendly and Tiny: 371 bytes (minified and gzipped) 7 | 8 | ## Installation 9 | 10 | npm install --save @captaincodeman/redux-connect-element 11 | 12 | ## Usage 13 | 14 | Your WebComponents can be kept 'pure' with no reference to Redux which helps to 15 | make them easily testable and reusable. They should accept properties to set their 16 | state and raise events to communicate their internal state changes. 17 | 18 | A great library for writing lightweight custom elements is 19 | [lit-element](https://github.com/Polymer/lit-element). Here's a very simple example: 20 | 21 | ```ts 22 | import { LitElement, property, html } from 'lit-element' 23 | 24 | export class MyElement extends LitElement { 25 | static get is() { return 'my-element' } 26 | 27 | @property({ type: String }) 28 | public name: string = 'unknown' 29 | 30 | onChange(e: Event) { 31 | this.dispatchEvent( 32 | new CustomEvent('name-changed', { 33 | bubbles: true, 34 | composed: trye, 35 | detail: e.target.value, 36 | }) 37 | ) 38 | } 39 | 40 | render() { 41 | return html` 42 |

Hello ${this.name}

43 | 44 | ` 45 | } 46 | } 47 | ``` 48 | 49 | This is the class you would import into tests - you can feed it whatever data you 50 | want with no need to setup external dependencies (such as Redux). 51 | 52 | The connection to Redux can now be defined separately by subclassing the element 53 | and providing mapping functions. These map the Redux State to the element properties 54 | (`mapState`) and the events to Redux Actions (`mapEvents`). 55 | 56 | The `mapState` method can map properties directly or you can make use of the 57 | [Reselect](https://github.com/reduxjs/reselect) library to memoize more complex 58 | projections. 59 | 60 | ```ts 61 | import { connect } from '@captaincodeman/redux-connect-element' 62 | import { store, State } from './store' 63 | import { MyElement } from './my-element' 64 | 65 | export class MyConnectedElement extends connect(store, MyElement) { 66 | // mapState provides the mapping of state to element properties 67 | // this can be direct or via reselect memoized functions 68 | mapState(state: State) { 69 | return { 70 | name: state.name, 71 | // or using a reselecy selector: 72 | // name: NameSelector(state), 73 | } 74 | }) 75 | 76 | // mapEvents provides the mapping of DOM events to redux actions 77 | // this can again be direct as shown below or using action creators 78 | mapEvents() { 79 | return { 80 | 'name-changed': (e: NameChangedEvent) => ({ 81 | type: 'CHANGE_NAME', 82 | payload: { name: e.detail.name } 83 | }) 84 | // or, using an action creator: 85 | // 'name-changed': (e: NameChangedEvent) => changeNameAction(e.detail.name) 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | Registering this element will make it 'connected' with it's properties kept in-sync 92 | with the Redux store and automatically re-rendered when they change. Mapped events 93 | are automatically dispatched to the store to mutate the state within Redux. 94 | 95 | ```ts 96 | import { MyElementConnected } from './my-element-connected' 97 | 98 | customElements.define(MyElement.is, MyElementConnected) 99 | ``` 100 | 101 | Of course if you prefer, you can include the `connect` mixin with the mapping functions 102 | directly in the element - having the split is entirely optional and down to personal 103 | style and application architecture. 104 | 105 | I prefer to have a separate project for an apps elements which are pure UI components 106 | that have state set by properties and communicate with events. The app then consumes 107 | these building-block elements and uses connected views to connect the UI to the Redux 108 | state store. 109 | 110 | ## Upgrading 111 | 112 | If upgrading from v1, note that the mapping functions have been renamed and simplified. 113 | 114 | ### State Mapping 115 | 116 | Instead of: 117 | 118 | ```js 119 | _mapStateToProps = (state: State) => ({ 120 | name: NameSelector(state) 121 | }) 122 | ``` 123 | 124 | Use: 125 | 126 | ```js 127 | mapState(state: State) { 128 | return { 129 | name: NameSelector(state), 130 | } 131 | }) 132 | ``` 133 | 134 | or 135 | 136 | ```js 137 | mapState = (state: State) => ({ 138 | name: NameSelector(state), 139 | }) 140 | ``` 141 | 142 | ### Event Mapping 143 | 144 | Instead of: 145 | ```js 146 | _mapEventsToActions = () => ({ 147 | 'name-changed': (e: NameChangedEvent) => changeNameAction(e.detail.name) 148 | }) 149 | ``` 150 | 151 | Or 152 | 153 | ```js 154 | _mapDispatchToEvents = (dispatch: Dispatch) => ({ 155 | 'name-changed': (e: NameChangedEvent) => dispatch(changeNameAction(e.detail.name)) 156 | }) 157 | ``` 158 | 159 | Use: 160 | 161 | ```js 162 | mapEvents() { 163 | return { 164 | 'name-changed': (e: NameChangedEvent) => changeNameAction(e.detail.name) 165 | } 166 | } 167 | ``` 168 | 169 | Or 170 | 171 | ```js 172 | mapEvents = () => ({ 173 | 'name-changed': (e: NameChangedEvent) => changeNameAction(e.detail.name) 174 | }) 175 | ``` 176 | --------------------------------------------------------------------------------