├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── components.d.ts ├── global │ ├── interfaces.ts │ └── store.ts └── index.ts ├── stencil.config.ts ├── test └── global │ └── store.ts └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | build-and-test: 10 | name: Build and Test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 12.x 16 | - uses: actions/checkout@v1 17 | - run: npm install 18 | - run: npm run build 19 | - run: npm test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | www/ 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.lock 8 | *.tmp 9 | *.tmp.* 10 | log.txt 11 | *.sublime-project 12 | *.sublime-workspace 13 | 14 | .idea/ 15 | .vscode/ 16 | .sass-cache/ 17 | .versions/ 18 | node_modules/ 19 | $RECYCLE.BIN/ 20 | 21 | .DS_Store 22 | Thumbs.db 23 | UserInterfaceState.xcuserstate 24 | .env 25 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "jsxBracketSameLine": false, 5 | "jsxSingleQuote": false, 6 | "quoteProps": "consistent", 7 | "printWidth": 180, 8 | "semi": true, 9 | "singleQuote": true, 10 | "tabWidth": 2, 11 | "trailingComma": "all", 12 | "useTabs": false 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ionic 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 | ## Stencil Redux 2 | 3 | A simple redux connector for Stencil-built web components inspired by [`react-redux`](https://github.com/reduxjs/react-redux). 4 | 5 | ## Install 6 | 7 | ``` 8 | npm install @stencil/redux 9 | npm install redux 10 | ``` 11 | 12 | ## Usage 13 | 14 | Stencil Redux uses the [`redux`](https://github.com/reduxjs/redux/) library underneath. Setting up the store and defining actions, reducers, selectors, etc. should be familiar to you if you've used React with Redux. 15 | 16 | ### Configure the Root Reducer 17 | 18 | ```typescript 19 | // redux/reducers.ts 20 | 21 | import { combineReducers } from 'redux'; 22 | 23 | // Import feature reducers and state interfaces. 24 | import { TodoState, todos } from './todos/reducers'; 25 | 26 | // This interface represents app state by nesting feature states. 27 | export interface RootState { 28 | todos: TodoState; 29 | } 30 | 31 | // Combine feature reducers into a single root reducer 32 | export const rootReducer = combineReducers({ 33 | todos, 34 | }); 35 | ``` 36 | 37 | ### Configure the Actions 38 | 39 | ```typescript 40 | // redux/actions.ts 41 | 42 | import { RootState } from './reducers'; 43 | 44 | // Import feature action interfaces 45 | import { TodoAction } from './todos/actions'; 46 | 47 | // Export all feature actions for easier access. 48 | export * from './todos/actions'; 49 | 50 | // Combine feature action interfaces into a base type. Use union types to 51 | // combine feature interfaces. 52 | // https://www.typescriptlang.org/docs/handbook/advanced-types.html#union-types 53 | export type Action = ( 54 | TodoAction 55 | ); 56 | ``` 57 | 58 | ### Configure the Store 59 | 60 | ```typescript 61 | // redux/store.ts 62 | 63 | import { Store, applyMiddleware, createStore } from 'redux'; 64 | import thunk from 'redux-thunk'; // add-on you may want 65 | import logger from 'redux-logger'; // add-on you may want 66 | 67 | import { RootState, rootReducer } from './reducers'; 68 | 69 | export const store: Store = createStore(rootReducer, applyMiddleware(thunk, logger)); 70 | ``` 71 | 72 | ### Configure Store in Root Component 73 | 74 | ```typescript 75 | 76 | // components/my-app/my-app.tsx 77 | 78 | import { store } from '@stencil/redux'; 79 | 80 | import { Action } from '../../redux/actions'; 81 | import { RootState } from '../../redux/reducers'; 82 | import { initialStore } from '../../redux/store'; 83 | 84 | @Component({ 85 | tag: 'my-app', 86 | styleUrl: 'my-app.scss' 87 | }) 88 | export class MyApp { 89 | 90 | componentWillLoad() { 91 | store.setStore(initialStore); 92 | } 93 | 94 | } 95 | ``` 96 | 97 | ### Map state and dispatch to props 98 | 99 | :memo: *Note*: Because the mapped props are technically changed *within* the component, `mutable: true` is required for `@Prop` definitions that utilize the store. See the [Stencil docs](https://stenciljs.com/docs/properties#prop-value-mutability) for info. 100 | 101 | ```typescript 102 | // components/my-component/my-component.tsx 103 | 104 | import { store, Unsubscribe } from '@stencil/redux'; 105 | 106 | import { Action, changeName } from '../../redux/actions'; 107 | import { RootState } from '../../redux/reducers'; 108 | 109 | @Component({ 110 | tag: 'my-component', 111 | styleUrl: 'my-component.scss' 112 | }) 113 | export class MyComponent { 114 | @Prop({ mutable: true }) name: string; 115 | 116 | changeName!: typeof changeName; 117 | 118 | unsubscribe!: Unsubscribe; 119 | 120 | componentWillLoad() { 121 | this.unsubscribe = store.mapStateToProps(this, state => { 122 | const { user: { name } } = state; 123 | return { name }; 124 | }); 125 | 126 | store.mapDispatchToProps(this, { changeName }); 127 | } 128 | 129 | componentDidUnload() { 130 | this.unsubscribe(); 131 | } 132 | 133 | doNameChange(newName: string) { 134 | this.changeName(newName); 135 | } 136 | } 137 | ``` 138 | 139 | ### Usage with `redux-thunk` 140 | 141 | Some Redux middleware, such as `redux-thunk`, alter the store's `dispatch()` function, resulting in type mismatches with mapped actions in your components. 142 | 143 | To properly type mapped actions in your components (properties whose values are set by `store.mapDispatchToProps()`), you can use the following type: 144 | 145 | ```typescript 146 | import { ThunkAction } from 'redux-thunk'; 147 | 148 | export type Unthunk = T extends (...args: infer A) => ThunkAction 149 | ? (...args: A) => R 150 | : T; 151 | ``` 152 | 153 | #### Example 154 | 155 | ```typescript 156 | // redux/user/actions.ts 157 | 158 | import { ThunkAction } from 'redux-thunk'; 159 | 160 | export const changeName = (name: string): ThunkAction, RootState, void, Action> => async (dispatch, getState) => { 161 | await fetch(...); // some async operation 162 | }; 163 | ``` 164 | 165 | In the component below, the type of `this.changeName` is extracted from the action type to be `(name: string) => Promise`. 166 | 167 | ```typescript 168 | // components/my-component/my-component.tsx 169 | 170 | import { changeName } from '../../redux/actions'; 171 | 172 | export class MyComponent { 173 | changeName!: Unthunk; 174 | 175 | componentWillLoad() { 176 | store.mapDispatchToProps(this, { changeName }); 177 | } 178 | } 179 | ``` 180 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | globals: { 4 | 'ts-jest': { 5 | diagnostics: { 6 | // warnOnly: true, 7 | }, 8 | tsConfig: { 9 | types: ['node', 'jest'], 10 | }, 11 | }, 12 | }, 13 | testRegex: 'test/.*.(ts|tsx)', 14 | testMatch: null, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stencil/redux", 3 | "version": "0.2.0", 4 | "description": "Stencil Redux - A simple redux-connector for Stencil-built web components", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/types/index.d.ts", 8 | "collection": "dist/collection/collection-manifest.json", 9 | "files": [ 10 | "dist/" 11 | ], 12 | "scripts": { 13 | "build": "stencil build", 14 | "start": "stencil build --es5 --dev --watch --serve --no-open --debug", 15 | "release": "npm run build && np", 16 | "test": "jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/ionic-team/stencil-redux.git" 21 | }, 22 | "devDependencies": { 23 | "@stencil/core": "^2.0.0-0", 24 | "@types/jest": "^26.0.9", 25 | "jest": "^26.3.0", 26 | "np": "^6.4.0", 27 | "redux": "^4.0.1", 28 | "ts-jest": "^26.2.0", 29 | "typescript": "^3.9.7" 30 | }, 31 | "peerDependencies": { 32 | "redux": "^4.0.1" 33 | }, 34 | "author": "Ionic Team", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/ionic-team/stencil-redux" 38 | }, 39 | "homepage": "https://github.com/ionic-team/stencil-redux" 40 | } 41 | -------------------------------------------------------------------------------- /src/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | /** 4 | * This is an autogenerated file created by the Stencil compiler. 5 | * It contains typing information for all components that exist in this project. 6 | */ 7 | import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; 8 | export namespace Components { 9 | 10 | } 11 | declare global { 12 | interface HTMLElementTagNameMap { 13 | } 14 | } 15 | declare namespace LocalJSX { 16 | interface IntrinsicElements { 17 | } 18 | } 19 | export { LocalJSX as JSX }; 20 | declare module "@stencil/core" { 21 | export namespace JSX { 22 | interface IntrinsicElements { 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/global/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Action as ReduxAction, AnyAction, Store as ReduxStore, Unsubscribe } from 'redux'; 2 | 3 | export interface Store { 4 | getState: () => S; 5 | getStore: () => ReduxStore; 6 | setStore: (store: ReduxStore) => void; 7 | mapStateToProps: (component: C, mapper: (state: S) => R) => Unsubscribe; 8 | mapDispatchToProps: (component: C, props: P) => void; 9 | } 10 | 11 | /** 12 | * @deprecated See README.md for new usage. 13 | */ 14 | export type Action = (...args: any[]) => any; 15 | 16 | export { Unsubscribe }; 17 | -------------------------------------------------------------------------------- /src/global/store.ts: -------------------------------------------------------------------------------- 1 | import { Store as ReduxStore } from 'redux'; 2 | 3 | import { Store } from './interfaces'; 4 | 5 | export const store = ((): Store => { 6 | let _store: ReduxStore; 7 | 8 | const setStore = (store: ReduxStore) => { 9 | _store = store; 10 | }; 11 | 12 | const getState = () => { 13 | return _store && _store.getState(); 14 | }; 15 | 16 | const getStore = () => { 17 | return _store; 18 | }; 19 | 20 | const mapDispatchToProps = (component: any, props: any) => { 21 | Object.keys(props).forEach(actionName => { 22 | const action = props[actionName]; 23 | Object.defineProperty(component, actionName, { 24 | get: () => (...args: any[]) => _store.dispatch(action(...args)), 25 | configurable: true, 26 | enumerable: true, 27 | }); 28 | }); 29 | }; 30 | 31 | const mapStateToProps = (component: any, mapState: (...args: any[]) => any) => { 32 | // TODO: Don't listen for each component 33 | const _mapStateToProps = (_component: any, _mapState: any) => { 34 | const mergeProps = mapState(_store.getState()); 35 | Object.keys(mergeProps).forEach(newPropName => { 36 | const newPropValue = mergeProps[newPropName]; 37 | component[newPropName] = newPropValue; 38 | // TODO: can we define new props and still have change detection work? 39 | }); 40 | }; 41 | 42 | const unsubscribe = _store.subscribe(() => _mapStateToProps(component, mapState)); 43 | 44 | _mapStateToProps(component, mapState); 45 | 46 | return unsubscribe; 47 | }; 48 | 49 | return { 50 | getStore, 51 | setStore, 52 | getState, 53 | mapDispatchToProps, 54 | mapStateToProps, 55 | }; 56 | })(); 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './global/interfaces'; 2 | export * from './global/store'; 3 | -------------------------------------------------------------------------------- /stencil.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@stencil/core'; 2 | 3 | export const config: Config = { 4 | namespace: 'stencilredux', 5 | outputTargets: [{ type: 'dist' }], 6 | globalScript: 'src/global/store.ts', 7 | }; 8 | -------------------------------------------------------------------------------- /test/global/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import { store } from '../../src/global/store'; 3 | 4 | describe('@stencil/redux', () => { 5 | describe('global/store', () => { 6 | it('should return same redux store', () => { 7 | const initialStore = createStore(() => {}); 8 | store.setStore(initialStore); 9 | expect(store.getStore()).toBe(initialStore); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "declaration": false, 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "lib": ["dom", "es2017"], 8 | "moduleResolution": "node", 9 | "module": "esnext", 10 | "target": "es2017", 11 | "strict": true, 12 | "jsx": "react", 13 | "jsxFactory": "h" 14 | }, 15 | "include": ["src", "types/jsx.d.ts"], 16 | "exclude": ["node_modules", "src/**/__tests__/*.ts"] 17 | } 18 | --------------------------------------------------------------------------------