├── .babelrc ├── .gitignore ├── .netlify ├── .prettierignore ├── .prettierrc ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── .vscode └── settings.json ├── README.md ├── jest.config.js ├── package.json ├── src ├── withProvider │ ├── README.md │ ├── index.tsx │ └── store.ts ├── withReduxMassive │ ├── README.md │ ├── components │ │ ├── App.tsx │ │ ├── __tests__ │ │ │ ├── App.test.tsx │ │ │ └── __snapshots__ │ │ │ │ └── App.test.tsx.snap │ │ ├── atoms │ │ │ ├── Button.tsx │ │ │ └── Greeting.tsx │ │ ├── organisms │ │ │ └── counter │ │ │ │ ├── Counter.tsx │ │ │ │ └── index.ts │ │ └── templates │ │ │ └── DefaultLayout.tsx │ ├── index.tsx │ ├── reducers │ │ ├── __tests__ │ │ │ └── counter.test.js │ │ ├── counter.ts │ │ └── index.ts │ └── store │ │ └── createStore.ts └── withReduxStarter │ ├── App.tsx │ ├── README.md │ ├── index.tsx │ └── store.ts ├── stories ├── Welcome.stories.tsx ├── withProvider.stories.tsx ├── withReduxMassive.tsx └── withReduxStarter.stories.tsx ├── tsconfig.json ├── tslint.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react", "power-assert"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | storybook-static -------------------------------------------------------------------------------- /.netlify: -------------------------------------------------------------------------------- 1 | {"site_id":"b70cde32-d108-4572-bfec-9250b7df59d8","path":"storybook-static"} -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /.stories.tsx?/) 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)) 7 | } 8 | 9 | configure(loadStories, module) 10 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | extensions: ['.ts', '.tsx', '.js'] 4 | }, 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.js$/, 9 | exclude: [/node_modules/], 10 | use: { 11 | loader: 'babel-loader', 12 | options: { 13 | forceEnv: 'development:client' 14 | } 15 | } 16 | }, 17 | { 18 | test: /\.tsx?$/, 19 | use: { 20 | loader: 'awesome-typescript-loader', 21 | options: { 22 | // forceEnv: 'development:client' 23 | useBabel: true, 24 | babelOptions: { 25 | babelrc: false, 26 | presets: ['env', 'react', 'power-assert'], 27 | plugins: ['transform-object-rest-spread'] 28 | }, 29 | babelCore: 'babel-core' 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typescript redux patterns 2 | 3 | WIP 4 | 5 | ## Run 6 | 7 | ``` 8 | yarn storybook 9 | ``` 10 | 11 | See storybook http://jolly-wescoff-2811e4.netlify.com 12 | 13 | ## Stack 14 | 15 | * React 16 | * Redux 17 | * TypeScript 18 | * styled-components 19 | * prettier 20 | * jest 21 | * storybook 22 | 23 | ## Patterns 24 | 25 | * Massive 26 | * WithRedux 27 | * WithProvider 28 | 29 | ## LICENSE 30 | 31 | MIT 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'setupTestFrameworkScriptFile': './testSetup.js', 3 | 'moduleFileExtensions': [ 4 | 'ts', 5 | 'tsx', 6 | 'js', 7 | 'jsx' 8 | ], 9 | 'transform': { 10 | '\\.(ts|tsx)$': 'ts-jest', 11 | '\\.(js|jsx)$': 'babel-jest', 12 | '\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform' 13 | }, 14 | 'modulePaths': [ 15 | './src' 16 | ], 17 | 'testMatch': [ 18 | '**/**/*.test.+(ts|tsx|js)' 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ts-patterns", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "build:dev": "webpack --mode development --watch --progress", 9 | "lint": "tslint src/**/*.ts src/**/*tsx --format stylish", 10 | "fmt": "prettier --config .prettierrc --write 'src/**' && tslint src/**/*.ts src/**/*tsx --fix", 11 | "test": "jest", 12 | "test:watch": "jest --watchAll", 13 | "storybook": "start-storybook -p 6006", 14 | "build-storybook": "build-storybook" 15 | }, 16 | "devDependencies": { 17 | "@storybook/addon-actions": "^3.4.2", 18 | "@storybook/addon-links": "^3.4.2", 19 | "@storybook/addons": "^3.4.2", 20 | "@storybook/react": "^3.4.2", 21 | "@types/react": "^16.3.12", 22 | "@types/react-redux": "^5.0.19", 23 | "@types/react-test-renderer": "^16.0.1", 24 | "@types/redux-logger": "^3.0.6", 25 | "@types/redux-promise": "^0.5.28", 26 | "@types/storybook__addon-actions": "^3.0.3", 27 | "@types/storybook__react": "^3.0.7", 28 | "awesome-typescript-loader": "4", 29 | "babel-core": "^6.26.3", 30 | "babel-jest": "^22.4.1", 31 | "babel-loader": "^7.1.3", 32 | "babel-preset-env": "^1.6.1", 33 | "babel-preset-power-assert": "^2.0.0", 34 | "babel-preset-react": "^6.24.1", 35 | "css-loader": "^0.28.11", 36 | "jest": "^22.4.2", 37 | "power-assert": "^1.5.0", 38 | "prettier": "^1.12.1", 39 | "react-test-renderer": "^16.3.2", 40 | "source-map-loader": "^0.2.3", 41 | "ts-jest": "^22.4.2", 42 | "tslint": "^5.9.1", 43 | "tslint-config-prettier": "^1.12.0", 44 | "typescript": "^2.8.1", 45 | "webpack": "^4.1.0", 46 | "webpack-cli": "^2.0.10", 47 | "webpack-dev-middleware": "^3.1.2" 48 | }, 49 | "dependencies": { 50 | "@types/jest": "^22.2.2", 51 | "@types/power-assert": "^1.4.29", 52 | "@types/prop-types": "^15.5.2", 53 | "@types/react-dom": "^16.0.4", 54 | "react": "^16.3.2", 55 | "react-dom": "^16.3.2", 56 | "react-redux": "^5.0.7", 57 | "recompose": "^0.27.0", 58 | "redux": "^4.0.0", 59 | "redux-logger": "^3.0.6", 60 | "redux-promise": "^0.5.3", 61 | "redux-thunk": "^2.3.0", 62 | "styled-components": "^3.2.6" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/withProvider/README.md: -------------------------------------------------------------------------------- 1 | # with Provider 2 | 3 | React v16 の Provider を使ったシンプルなパターン 4 | 5 | WIP 6 | -------------------------------------------------------------------------------- /src/withProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ReactDOM from "react-dom" 3 | import { connect, Provider } from "react-redux" 4 | import { applyMiddleware, createStore } from "redux" 5 | import store from "./store" 6 | 7 | const StoreContext = React.createContext("store") 8 | 9 | class StoreProvider extends React.Component { 10 | public state: { theme: string } = { theme: "light" } 11 | public render() { 12 | return ( 13 | 14 | {this.props.children} 15 | 16 | ) 17 | } 18 | } 19 | 20 | export default () => { 21 | return ( 22 | 23 | {val =>
{val}
}
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/withProvider/store.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from "redux" 2 | import loggerMiddleware from "redux-logger" 3 | import thunkMiddleware from "redux-thunk" 4 | 5 | const INCREMENT = "counter/increment" 6 | 7 | type Increment = { 8 | type: typeof INCREMENT 9 | } 10 | 11 | export function increment(): Increment { 12 | return { 13 | type: INCREMENT 14 | } 15 | } 16 | 17 | type Action = Increment 18 | 19 | // state 20 | export type State = { 21 | value: number 22 | } 23 | 24 | const initialState: State = { 25 | value: 0 26 | } 27 | 28 | const counter = (state: State = initialState, action: Action) => { 29 | switch (action.type) { 30 | case INCREMENT: { 31 | return { 32 | ...state, 33 | value: state.value + 1 34 | } 35 | } 36 | default: { 37 | return state 38 | } 39 | } 40 | } 41 | 42 | const store = createStore( 43 | counter, 44 | applyMiddleware(loggerMiddleware, thunkMiddleware) 45 | ) 46 | 47 | export default store 48 | -------------------------------------------------------------------------------- /src/withReduxMassive/README.md: -------------------------------------------------------------------------------- 1 | # WithReduxMassive 2 | 3 | 巨大な Redux アプリケーションの例 4 | 5 | ## 採用したもの 6 | 7 | * typescript 8 | * prettier 9 | * tslint 10 | * react 11 | * redux 12 | * jest 13 | * styled-components 14 | * dux pattern 15 | 16 | ## 重視したこと 17 | 18 | * 中規模のコードを管理する 19 | * 実装パターンを容易する 20 | * react component 21 | * reducer 22 | * そのテスト 23 | * 骨組みを決めれば自動的にコードの場所が決まる 24 | * コピペからはじめられる 25 | 26 | ## 状態の考え方 27 | 28 | すべてを reducer に集約する。副作用で起きる場所を一箇所に集約する(redux store) 29 | シングルコンポーネントの setState でもいいが、結果として自前の Store 層になりがち「redux を小さく使う」のがどういう路線になっても無駄がないと思う。 30 | 31 | ## なぜ型を持っているか考える 32 | 33 | フロントエンドは DOM のテストを書くのが難しい。なので静的検査の力を借りたい。 34 | 35 | React の開発で一番問題が起きるのは component 間の props の不整合であり、最悪ここの静的検査があれば十分。また、ルートに近い要素で、Snapshot テストで差分が発生したか、しなかったを意識する。リファクタしたはずなのに差分が発生しているというミスが検知できる。 36 | 37 | そして、いわゆるドメインロジックというものは Store に集中する。 Action と Reducer の関係を適切にテストしておけばよい。しかし外部 IO がメインになると、静的検査が効かなくなり、Reducer のテストに意味はなくなっていく。 38 | 39 | ## styled-components 40 | 41 | * 前向きな理由: CSS のメンテナンス性の低下理由の殆どは、コンポーネントの親子関係を破壊するセレクタで、 CSS in JS はその Component に対して閉じた装飾を行う。これはコンポーネント志向の設計ならば良い方向に働く。 42 | * 後ろ向きな理由: Webpack のメンテナンス困難を引き起こすのはほとんど CSS Modules で、これを避けると CSS in JS から選ぶことになり、一番勢いがあるのが styled 43 | * おまけの理由: これを覚えたら ReactNative でも使える(CSS in JS しかない) 44 | 45 | typescript + vscode + styled-components ならインラインでもハイライトできるし、prettier の CSS 整形も掛かる。 46 | 47 | 難点は CSS の外注がしづらい。キャッチアップに 3 日は掛かるかもしれない。 48 | 49 | ## Lint の役割 50 | 51 | * レビューコストを下げる 52 | * デッドコードを出さない 53 | * アンチパターンを踏ませない 54 | * ホワイトスペース周りはフォーマッタに任せる 55 | 56 | ということで prettier に空白周りを全部任せつつ、tslint で経験上、コード品質に一番効くのは 未使用変数の警告で、最悪これさえ消しておけば良い。 57 | 58 | ## Reducer と Action Creator / Dux pattern 59 | 60 | `reduces/*.ts` で reducer を export default しつつ、そのスコープで action を export fuction する。 61 | 62 | 理由としては、 action と reducer は疎であることは稀で、基本的に一意に決まる。推論機に多くヒントを与えるためにも凝集性を重視する。 63 | 64 | Reducer は Object Rest Spread を使いつつ更新する 65 | 66 | ## インターフェースの命名規則 67 | 68 | MS の命名規則では、IState や IAction など、Interface には I を付けることが多く, tslint でもそうなっているが、GitHub に上がっているコードを見る限りこれに従っているのは C#派だけで、基本的に無視されている。個人的にも不要であると思う。 69 | 70 | ## アトミックデザインの借用 71 | 72 | React は コンポーネント志向のライブラリであるため、コンポーネント志向のデザインパターンと相性がいい。ということでアトミックデザインの用語と分類を借用する。 73 | 74 | * Atoms: 他に依存しないコンポーネント 75 | * Text 76 | * Button 77 | * Molecules: 複数の Atom からなるコンポーネント 78 | * Header 79 | * Organisms: 複数の Atom と Molecules からなるコンポーネント兼 Container 80 | * Counter 81 | * Pages: ルーティング時の複数のエントリポイント 82 | * Template: レイアウト 83 | 84 | 大事なのは、コンポーネントの再利用性のみで Component を分類し、**分類名でディレクトリを切らない**。 85 | 86 | 注意: components/container パターンではない。organisms の層で connect している。ここで、Pages/Templates はかけ離れてる点に注意 87 | 88 | [Atomic Design を実案件に導入して運用してみた結果はどうなのか - I'm kubosho\_](http://blog.kubosho.com/entry/atomic-design-on-abematv) 89 | 90 | [danilowoz/react-atomic-design: Boilerplate with the methodology Atomic Design using a few cool things.](https://github.com/danilowoz/react-atomic-design) 91 | 92 | ## 未解決 93 | 94 | * TypeScript のベストプラクティスを調べきれていない 95 | * redux v4 が壊れている 96 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Provider } from "react-redux" 3 | import createStore from "../store/createStore" 4 | import Greeting from "./atoms/Greeting" 5 | import Counter from "./organisms/counter" 6 | 7 | export default () => ( 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as renderer from 'react-test-renderer' 3 | import App from '../App' 4 | 5 | test('render ', () => { 6 | const tree = renderer.create().toJSON() 7 | expect(tree).toMatchSnapshot() 8 | }) 9 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/__tests__/__snapshots__/App.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render 1`] = ` 4 |
7 |
10 |

13 | Counter 14 |

15 | 18 | value: 19 | 0 20 | 21 |
24 | 30 |   31 | 37 |
38 |
39 |
40 | `; 41 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/atoms/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import styled from "styled-components" 3 | 4 | type Props = { 5 | text: string 6 | onClick: (event: any) => void 7 | } 8 | 9 | export default function Button({ text, onClick }: Props) { 10 | return {text} 11 | } 12 | 13 | const StyledButton = styled.button` 14 | display: inline-block; 15 | padding: 0.5em 1em; 16 | text-decoration: none; 17 | background: #668ad8; 18 | color: #fff; 19 | border-bottom: solid 4px #627295; 20 | border-radius: 3px; 21 | 22 | :active { 23 | -ms-transform: translateY(4px); 24 | -webkit-transform: translateY(4px); 25 | transform: translateY(4px); 26 | border-bottom: none; 27 | } 28 | ` 29 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/atoms/Greeting.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | type Props = { 4 | name: string 5 | } 6 | 7 | export default function Greeting({ name }: Props) { 8 | return ( 9 |
10 |
Hello {name}
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/withReduxMassive/components/organisms/counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { connect } from "react-redux" 3 | import styled from "styled-components" 4 | import Button from "../../atoms/Button" 5 | import DefaultLayout from "../../templates/DefaultLayout" 6 | 7 | type Props = { 8 | value: number 9 | add(n: number): void 10 | increment(): void 11 | } 12 | 13 | export default function Counter(props: Props) { 14 | const { value, add, increment } = props 15 | return ( 16 | 17 | 18 | Counter 19 | value: {props.value} 20 | 21 | 24 | 25 | 26 | ) 27 | }) 28 | -------------------------------------------------------------------------------- /src/withReduxStarter/README.md: -------------------------------------------------------------------------------- 1 | # WithReduxStarter 2 | 3 | Store 管理に redux を薄く使うシンプルなパターン。単一 reducer。 4 | 5 | 複雑になったときは、 withReduxMassive を参照 6 | -------------------------------------------------------------------------------- /src/withReduxStarter/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ReactDOM from "react-dom" 3 | import { connect, Provider } from "react-redux" 4 | import { applyMiddleware, createStore } from "redux" 5 | import App from "./App" 6 | import store from "./store" 7 | 8 | export default () => { 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/withReduxStarter/store.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from "redux" 2 | import loggerMiddleware from "redux-logger" 3 | import thunkMiddleware, { ThunkAction } from "redux-thunk" 4 | 5 | const INCREMENT = "counter/increment" 6 | 7 | type Increment = { 8 | type: typeof INCREMENT 9 | } 10 | 11 | export function increment(): Increment { 12 | return { 13 | type: INCREMENT 14 | } 15 | } 16 | 17 | export function incrementAsync(): ThunkAction { 18 | return dispatch => { 19 | dispatch(increment()) 20 | } 21 | } 22 | 23 | type Action = Increment 24 | 25 | // state 26 | export type State = { 27 | value: number 28 | } 29 | 30 | const initialState: State = { 31 | value: 0 32 | } 33 | 34 | const counter = (state: State = initialState, action: Action) => { 35 | switch (action.type) { 36 | case INCREMENT: { 37 | return { 38 | ...state, 39 | value: state.value + 1 40 | } 41 | } 42 | default: { 43 | return state 44 | } 45 | } 46 | } 47 | 48 | const store = createStore( 49 | counter, 50 | applyMiddleware(loggerMiddleware, thunkMiddleware) 51 | ) 52 | 53 | export default store 54 | -------------------------------------------------------------------------------- /stories/Welcome.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react" 2 | import * as React from "react" 3 | 4 | storiesOf("Welcome", module).add("to Storybook", () =>

Welcome

) 5 | -------------------------------------------------------------------------------- /stories/withProvider.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react" 2 | import * as React from "react" 3 | import WithProvider from "../src/withProvider" 4 | 5 | storiesOf("WithProvider", module).add("to Storybook", () => ) 6 | -------------------------------------------------------------------------------- /stories/withReduxMassive.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react" 2 | import * as React from "react" 3 | import WithReduxMassive from "../src/withReduxMassive/components/App" 4 | 5 | storiesOf("WithRedux", module).add("to Storybook", () => ) 6 | -------------------------------------------------------------------------------- /stories/withReduxStarter.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react" 2 | import * as React from "react" 3 | import Massive from "../src/withReduxStarter" 4 | 5 | storiesOf("Massive", module).add("to Storybook", () => ) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": "./src/", 5 | "outDir": "./dist/", 6 | "sourceMap": true, 7 | "noImplicitAny": true, 8 | "module": "commonjs", 9 | "target": "esnext", 10 | "jsx": "react", 11 | "allowJs": true 12 | }, 13 | "include": ["./src/**/*"], 14 | "exclude": ["node_modules", "./src/**/*.test.ts", "./src/**/*.test.tsx"] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "interface-over-type-literal": false, 7 | "object-literal-sort-keys": false, 8 | "no-implicit-dependencies": [true, "dev"] 9 | }, 10 | "rulesDirectory": [] 11 | } 12 | --------------------------------------------------------------------------------