├── .gitignore ├── tslint.json ├── src ├── styles │ ├── main │ │ ├── _animations.css │ │ ├── _type.css │ │ └── _buttons.css │ ├── _mixins.css │ ├── _vars.css │ └── main.css ├── stores │ ├── BasicUsageStore.ts │ ├── DynamicDependenciesStore.ts │ ├── RouterStore.ts │ ├── EfficientNestedRerendersStore.ts │ ├── GranularRerendersStore.ts │ ├── ComputedPropertiesStore.ts │ ├── TodosStore.ts │ └── BatchedMutationsStore.ts ├── main │ ├── createHistory.ts │ ├── createMobxStores.ts │ └── createRoutes.tsx ├── components │ ├── Link │ │ ├── style.css │ │ └── index.tsx │ ├── AppHeaderLink │ │ ├── style.css │ │ └── index.tsx │ └── AppHeader │ │ ├── index.tsx │ │ └── style.css ├── models │ └── TodoModel.ts ├── globals.d.ts ├── pages │ ├── App.tsx │ ├── TodosPage │ │ ├── Todo.tsx │ │ ├── NewTodo.tsx │ │ ├── TodoViewControls.tsx │ │ └── index.tsx │ ├── IndexPage.tsx │ ├── BasicUsagePage │ │ └── index.tsx │ ├── GranularRerendersPage │ │ ├── Item.tsx │ │ ├── ItemList.tsx │ │ └── index.tsx │ ├── EfficientNestedRerendersPage │ │ ├── CounterDisplay.tsx │ │ └── index.tsx │ ├── ComputedPropertiesPage │ │ └── index.tsx │ ├── DynamicDependenciesPage │ │ └── index.tsx │ └── BatchedMutationsPage │ │ └── index.tsx ├── index.tsx └── types.ts ├── tsconfig.json ├── tsd.json ├── index.html ├── license ├── package.json ├── webpack.config.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | .vscode/ 4 | typings/ 5 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-strictish"], 3 | "rules": { 4 | "member-access": false, 5 | "typedef": false, 6 | "eofline": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/main/_animations.css: -------------------------------------------------------------------------------- 1 | @keyframes hanging-fade-out { 2 | 0% { 3 | opacity: 1.0; 4 | } 5 | 33% { 6 | opacity: 1.0; 7 | } 8 | 100% { 9 | opacity: 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/stores/BasicUsageStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | export default class BasicUsageStore { 4 | @observable counter = 0; 5 | 6 | @action increment = (): void => { 7 | this.counter++; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/main/createHistory.ts: -------------------------------------------------------------------------------- 1 | import {hashHistory, createMemoryHistory} from 'react-router'; 2 | 3 | /** 4 | * Creates the react-router-redux history object. 5 | */ 6 | export default function createHistory(): HistoryModule.History { 7 | return __TEST__ ? createMemoryHistory() : hashHistory; 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/_mixins.css: -------------------------------------------------------------------------------- 1 | @define-mixin clickable-bg $bg-color-1, $bg-color-2 { 2 | background-color: $bg-color-1; 3 | 4 | &:hover { 5 | background-color: color($bg-color-1 blend($bg-color-2 15%)); 6 | } 7 | 8 | &:active { 9 | background-color: color($bg-color-1 blend($bg-color-2 30%)); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/stores/DynamicDependenciesStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | export default class DynamicDependenciesStore { 4 | @observable counter = 0; 5 | 6 | @action increment = (): void => { 7 | this.counter++; 8 | }; 9 | 10 | @action reset = (): void => { 11 | this.counter = 0; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Link/style.css: -------------------------------------------------------------------------------- 1 | @import '../../styles/_vars'; 2 | 3 | a { 4 | color: $primary-color-darker; 5 | 6 | &:visited { 7 | color: $accent-color-darker; 8 | } 9 | 10 | /* convention is */ 11 | &.active { 12 | cursor: default; 13 | } 14 | } 15 | 16 | .link-unstyled { 17 | text-decoration: none; 18 | outline: none; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/main/_type.css: -------------------------------------------------------------------------------- 1 | @import '../_vars'; 2 | 3 | .text-block { 4 | max-width: 500px; 5 | margin: $spacing-lg auto 0; 6 | padding: 0 $spacing-base; 7 | } 8 | 9 | h1 { 10 | font-size: $font-size-h1; 11 | font-weight: 300; 12 | } 13 | 14 | h2 { 15 | font-size: $font-size-h2; 16 | font-weight: 300; 17 | } 18 | 19 | h3 { 20 | font-size: $font-size-h3; 21 | font-weight: 300; 22 | } -------------------------------------------------------------------------------- /src/models/TodoModel.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | let _id = 0; 4 | 5 | export default class TodoModel { 6 | id = ++_id; 7 | 8 | @observable text = ''; 9 | @observable isComplete = false; 10 | 11 | constructor(text: string) { 12 | this.text = text; 13 | } 14 | 15 | @action toggleComplete = (): void => { 16 | this.isComplete = !this.isComplete; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | type nodeEnv = 'production' | 'development'; 2 | interface Process { 3 | env: { 4 | NODE_ENV: nodeEnv; 5 | }; 6 | } 7 | declare var process: Process; 8 | 9 | declare var __DEV__: boolean; 10 | declare var __PROD__: boolean; 11 | declare var __TEST__: boolean; 12 | 13 | interface Dict { 14 | [key: string]: T; 15 | } 16 | 17 | interface Window { 18 | ga(...args: any[]): void; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "sourceMap": true, 6 | "noImplicitAny": true, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "preserveConstEnums": true, 10 | "experimentalDecorators": true, 11 | "strictNullChecks": true, 12 | "skipLibCheck": true, 13 | "jsx": "react" 14 | }, 15 | "exclude": [ 16 | "node_modules" 17 | ] 18 | } -------------------------------------------------------------------------------- /src/pages/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {AppHeader} from '../components/AppHeader'; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | } 7 | 8 | export default class App extends React.Component { 9 | render(): JSX.Element { 10 | const {children} = this.props; 11 | return ( 12 |
13 | 14 | {children} 15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/stores/RouterStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | export default class RouterStore { 4 | @observable path: string; 5 | 6 | constructor(history: HistoryModule.History) { 7 | // TODO teardown, dispose in abstract base store? 8 | history.listen((location: HistoryModule.Location): void => { 9 | this.updatePath(location); 10 | window.ga('send', 'pageview'); 11 | }); 12 | } 13 | 14 | @action updatePath = (location: HistoryModule.Location): void => { 15 | this.path = location.key; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/stores/EfficientNestedRerendersStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | function getDefaultRenderCounts(): number[] { 4 | return [-1, -1, -1]; 5 | } 6 | 7 | export default class EfficientNestedRerendersStore { 8 | @observable counter = 0; 9 | 10 | // The render counts are stored here instead of on the components 11 | // because mobx-react's experimental @inject appears to be broken with refs right now. 12 | renderCounts = getDefaultRenderCounts(); 13 | 14 | @action increment = (): void => { 15 | this.counter++; 16 | }; 17 | 18 | @action reset = (): void => { 19 | this.counter = 0; 20 | this.renderCounts = getDefaultRenderCounts(); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/AppHeaderLink/style.css: -------------------------------------------------------------------------------- 1 | @import '../../styles/_vars'; 2 | @import '../../styles/_mixins'; 3 | 4 | .app-header-link { 5 | flex-grow: 1; 6 | flex-basis: 0; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | min-height: $nav-height; 11 | color: $text-color; 12 | padding: $spacing-xs $spacing-sm; 13 | color: $primary-color-text; 14 | text-shadow: 1px 1px 2px $primary-color-darker; 15 | border-radius: 3px 3px 0 0; 16 | 17 | @mixin clickable-bg $primary-color, $bg-color; 18 | 19 | &:visited { 20 | color: $primary-color-text; 21 | } 22 | 23 | &.active { 24 | background-color: $bg-color; 25 | color: $text-color; 26 | text-shadow: none; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/AppHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import Link from '../Link'; 4 | import { experimentNames } from '../../types'; 5 | import AppHeaderLink from '../AppHeaderLink'; 6 | 7 | import './style.css'; 8 | 9 | interface Props { 10 | title: string; 11 | } 12 | 13 | export const AppHeader = observer(({title}: Props) => ( 14 |
15 | 16 |

{title}

17 | 18 |
19 | {experimentNames.map((name) => ( 20 | 21 | ))} 22 |
23 |
24 | )); 25 | -------------------------------------------------------------------------------- /tsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v4", 3 | "repo": "borisyankov/DefinitelyTyped", 4 | "ref": "master", 5 | "path": "typings", 6 | "bundle": "typings/tsd.d.ts", 7 | "installed": { 8 | "react/react.d.ts": { 9 | "commit": "448b1ab712df827697d6e7a350bfb6737a00a463" 10 | }, 11 | "react/react-dom.d.ts": { 12 | "commit": "448b1ab712df827697d6e7a350bfb6737a00a463" 13 | }, 14 | "react-router/react-router.d.ts": { 15 | "commit": "448b1ab712df827697d6e7a350bfb6737a00a463" 16 | }, 17 | "react-router/history.d.ts": { 18 | "commit": "448b1ab712df827697d6e7a350bfb6737a00a463" 19 | }, 20 | "lodash/lodash.d.ts": { 21 | "commit": "448b1ab712df827697d6e7a350bfb6737a00a463" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React MobX TypeScript Experiments 5 | 6 | 7 | 8 |
9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/stores/GranularRerendersStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, action} from 'mobx'; 2 | 3 | export interface ItemModel { 4 | id: number; 5 | isChecked: boolean; 6 | } 7 | 8 | let _id = 0; 9 | function createItem(): ItemModel { 10 | return { 11 | id: ++_id, 12 | isChecked: false, 13 | }; 14 | } 15 | 16 | function createDefaultItems(): ItemModel[] { 17 | return [createItem(), createItem(), createItem()]; 18 | } 19 | 20 | export default class GranularRerendersStore { 21 | @observable items: ItemModel[] = createDefaultItems(); 22 | 23 | @action addItem = (): void => { 24 | this.items.push(createItem()); 25 | }; 26 | 27 | @action toggleItem = (item: ItemModel): void => { 28 | item.isChecked = !item.isChecked; 29 | }; 30 | 31 | @action reset = (): void => { 32 | _id = 0; 33 | this.items = createDefaultItems(); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/main/_buttons.css: -------------------------------------------------------------------------------- 1 | @import '../_vars'; 2 | 3 | .btn { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | height: $button-height; 8 | text-transform: uppercase; 9 | font-weight: bold; 10 | border-radius: 3px; 11 | padding: 0 $spacing-base; 12 | font-size: $font-size-sm; 13 | border-right: 2px solid #aaa; 14 | border-bottom: 2px solid #aaa; 15 | border-top: 1px solid #bbb; 16 | border-left: 2px solid #aaa; 17 | 18 | &:active { 19 | border-right: 2px solid #aaa; 20 | border-bottom: 1px solid #bbb; 21 | border-top: 2px solid #aaa; 22 | border-left: 2px solid #aaa; 23 | } 24 | 25 | &:focus { 26 | outline: none; 27 | } 28 | } 29 | 30 | .btns-vertical { 31 | button { 32 | display: block; 33 | margin-bottom: 3px; 34 | &:last-child { 35 | margin-bottom: 0; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/styles/_vars.css: -------------------------------------------------------------------------------- 1 | $primary-color: #89c; 2 | $primary-color-light: color($primary-color l(+12%)); 3 | $primary-color-dark: color($primary-color l(-10%)); 4 | $primary-color-darker: color($primary-color l(-16%)); 5 | $accent-color: #b7b; 6 | $accent-color-light: color($accent-color l(+12%)); 7 | $accent-color-dark: color($accent-color l(-10%)); 8 | $accent-color-darker: color($accent-color l(-16%)); 9 | $error-color: #c33; 10 | $success-color: $accent-color-darker; 11 | 12 | $text-color: #333; 13 | $primary-color-text: #fff; 14 | 15 | $bg-color: #fff; 16 | $border-color: #bbb; 17 | 18 | $nav-height: 48px; 19 | $spacing-base: 15px; 20 | $spacing-sm: 10px; 21 | $spacing-xs: 5px; 22 | $spacing-lg: 30px; 23 | $button-height: 35px; 24 | 25 | $font-size-base: 16px; 26 | $font-size-sm: 13px; 27 | $font-size-h3: 20px; 28 | $font-size-h2: 24px; 29 | $font-size-h1: 32px; 30 | $golden-mean: 1.618033988749895; 31 | -------------------------------------------------------------------------------- /src/components/AppHeaderLink/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Link from '../Link'; 3 | import {ExperimentName} from '../../types'; 4 | 5 | import './style.css'; 6 | 7 | function getPath(name: ExperimentName): string { 8 | return '/' + name; 9 | } 10 | 11 | export function getExperimentNameTitle(name: ExperimentName): string { 12 | switch (name) { 13 | case 'todos': return 'todo list'; 14 | default: return name.replace(/-/g, ' ').toLowerCase(); 15 | } 16 | } 17 | 18 | interface Props { 19 | experimentName: ExperimentName; 20 | } 21 | 22 | export default class AppHeaderLink extends React.Component { 23 | render(): JSX.Element { 24 | const {experimentName} = this.props; 25 | return ( 26 | 27 | {getExperimentNameTitle(experimentName)} 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/AppHeader/style.css: -------------------------------------------------------------------------------- 1 | @import '../../styles/_vars'; 2 | 3 | .app-header { 4 | text-align: center; 5 | background-color: $primary-color; 6 | 7 | a { 8 | h1 { 9 | color: $primary-color-text; 10 | text-decoration: none; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | margin: 0; 15 | min-height: calc($nav-height * $golden-mean); 16 | text-shadow: 1px 1px 0 $primary-color-darker; 17 | 18 | &:hover { 19 | text-decoration: underline; 20 | } 21 | } 22 | 23 | &.active { 24 | h1 { 25 | text-decoration: none; 26 | } 27 | } 28 | } 29 | } 30 | 31 | .app-header-nav { 32 | min-height: $nav-height; 33 | display: flex; 34 | flex-wrap: wrap; 35 | } 36 | 37 | .app-header-nav-link { 38 | flex-grow: 1; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | } -------------------------------------------------------------------------------- /src/pages/TodosPage/Todo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer} from 'mobx-react'; 3 | import TodoModel from '../../models/TodoModel'; 4 | 5 | interface Props { 6 | todo: TodoModel; 7 | onRemove(todo: TodoModel): void; 8 | } 9 | 10 | @observer 11 | export default class Todo extends React.Component { 12 | render(): JSX.Element { 13 | const {todo} = this.props; 14 | return ( 15 |
16 | 23 |
24 | ); 25 | } 26 | 27 | doRemove = (): void => { 28 | this.props.onRemove(this.props.todo); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/IndexPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /* tslint:disable:max-line-length */ 4 | 5 | export default class IndexPage extends React.Component<{}, {}> { 6 | render(): JSX.Element { 7 | return ( 8 |
9 |

10 | MobX concept demos with React and TypeScript. 11 |

12 |

13 | Click through the links above to check out the different demos. 14 | If you have any suggestions or questions, 15 | please open an issue! 16 |

17 |

18 | See the GitHub project for 19 | source code and a discussion on the project's motivation and findings. 20 |

21 |
22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | @import './_vars'; 2 | 3 | /** 4 | * The contents of `./main` should all be imported here and only here, 5 | * otherwise they will be duplicated at each import site. 6 | */ 7 | @import './main/_animations'; 8 | @import './main/_buttons'; 9 | @import './main/_type'; 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | 15 | html { 16 | height: 100%; 17 | } 18 | 19 | body { 20 | height: 100%; 21 | color: $text-color; 22 | background-color: $bg-color; 23 | font-size: $font-size-base; 24 | font-family: sans-serif; 25 | line-height: 1.4; 26 | } 27 | 28 | p { 29 | margin-top: 0; 30 | } 31 | 32 | // #app-wrapper {} 33 | 34 | .page { 35 | flex: 1; 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: center; 39 | padding: $spacing-base $spacing-sm; 40 | max-width: 400px; 41 | margin: 0 auto; 42 | } 43 | 44 | .form-group { 45 | margin-bottom: $spacing-base; 46 | } 47 | 48 | select { 49 | padding: $spacing-xs; 50 | } 51 | 52 | input[type="checkbox"] { 53 | margin-right: 8px; 54 | } 55 | -------------------------------------------------------------------------------- /src/stores/ComputedPropertiesStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, computed, action} from 'mobx'; 2 | 3 | const DEFAULT_FIRST_NAME = 'Jimi'; 4 | const DEFAULT_LAST_NAME = 'Jackson'; 5 | 6 | export default class ComputedPropertiesStore { 7 | @observable firstName = DEFAULT_FIRST_NAME; 8 | @observable lastName = DEFAULT_LAST_NAME; 9 | 10 | // This is used to track the number of times `fullName` is computed. 11 | fullNameComputeCount = 0; 12 | 13 | @computed get fullName(): string { 14 | // This is a hack to track change counts. 15 | // Getters shouldn't mutate stuff in real code. 16 | this.fullNameComputeCount++; 17 | 18 | return this.firstName + ' ' + this.lastName; 19 | } 20 | 21 | @action updateFirstName = (firstName: string): void => { 22 | this.firstName = firstName; 23 | }; 24 | 25 | @action updateLastName = (lastName: string): void => { 26 | this.lastName = lastName; 27 | }; 28 | 29 | @action reset = (): void => { 30 | this.firstName = DEFAULT_FIRST_NAME; 31 | this.lastName = DEFAULT_LAST_NAME; 32 | this.fullNameComputeCount = 0; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/BasicUsagePage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import BasicUsageStore from '../../stores/BasicUsageStore'; 4 | import {Stores} from '../../types'; 5 | 6 | interface SelectedStores { 7 | store?: BasicUsageStore; 8 | } 9 | 10 | interface Props extends SelectedStores {} 11 | 12 | @inject((stores: Stores): Props => ({store: stores.basicUsageStore})) 13 | @observer 14 | export default class BasicUsagePage extends React.Component { 15 | render(): JSX.Element { 16 | const {store} = this.props; 17 | return ( 18 |
19 |

20 | MobX makes React components automatically react to the data changes 21 | that their render funtions implicitly depend on. 22 | No explicit subscriptions are needed to achieve granular re-renders. 23 |

24 |
{store!.counter}
25 |
26 | 29 |
30 |
31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/GranularRerendersPage/Item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer} from 'mobx-react'; 3 | import {ItemModel} from '../../stores/GranularRerendersStore'; 4 | 5 | interface Props { 6 | item: ItemModel; 7 | onToggle(item: ItemModel): void; 8 | } 9 | 10 | @observer 11 | export default class Item extends React.Component { 12 | renderCount = -1; 13 | 14 | render(): JSX.Element { 15 | const {item} = this.props; 16 | 17 | // This is a hack to demonstrate some MobX behavior. 18 | // In normal code render functions should be kept free of side-effects. 19 | this.renderCount++; 20 | 21 | return ( 22 |
23 | 27 |
28 | ); 29 | } 30 | 31 | doToggle = (): void => { 32 | this.props.onToggle(this.props.item); 33 | } 34 | 35 | resetRenderCount(): void { 36 | this.renderCount = -1; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/TodosPage/NewTodo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer} from 'mobx-react'; 3 | import TodosStore from '../../stores/TodosStore'; 4 | 5 | interface Props { 6 | todosStore: TodosStore; 7 | } 8 | 9 | @observer 10 | export default class NewTodo extends React.Component { 11 | render(): JSX.Element { 12 | const {todosStore} = this.props; 13 | return ( 14 |
15 |
16 | 19 | 24 |
25 |
26 | ); 27 | } 28 | 29 | doChangeText = (e: React.FormEvent): void => { 30 | const target = e.target as HTMLInputElement; 31 | this.props.todosStore.updateNewTodoText(target.value); 32 | }; 33 | 34 | doAddTodo = (): void => { 35 | this.props.todosStore.addTodo(this.props.todosStore.newTodoText); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/createMobxStores.ts: -------------------------------------------------------------------------------- 1 | import {Stores} from '../types'; 2 | import RouterStore from '../stores/RouterStore'; 3 | import BasicUsageStore from '../stores/BasicUsageStore'; 4 | import ComputedPropertiesStore from '../stores/ComputedPropertiesStore'; 5 | import EfficientNestedRerendersStore from '../stores/EfficientNestedRerendersStore'; 6 | import GranularRerendersStore from '../stores/GranularRerendersStore'; 7 | import DynamicDependenciesStore from '../stores/DynamicDependenciesStore'; 8 | import BatchedMutationsStore from '../stores/BatchedMutationsStore'; 9 | import TodosStore from '../stores/TodosStore'; 10 | 11 | /** 12 | * Creates the MobX stores. 13 | */ 14 | export default function createMobxStores(history: HistoryModule.History): Stores { 15 | return { 16 | routerStore: new RouterStore(history), 17 | basicUsageStore: new BasicUsageStore(), 18 | computedPropertiesStore: new ComputedPropertiesStore(), 19 | efficientNestedRerendersStore: new EfficientNestedRerendersStore(), 20 | granularRerendersStore: new GranularRerendersStore(), 21 | dynamicDependenciesStore: new DynamicDependenciesStore(), 22 | batchedMutationsStore: new BatchedMutationsStore(), 23 | todosStore: new TodosStore(), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/TodosPage/TodoViewControls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer} from 'mobx-react'; 3 | import TodosStore, {TodosView, views} from '../../stores/TodosStore'; 4 | 5 | interface Props { 6 | todosStore: TodosStore; 7 | } 8 | 9 | @observer 10 | export default class TodoViewControls extends React.Component { 11 | render(): JSX.Element { 12 | const {todosStore} = this.props; 13 | return ( 14 |
15 | 20 |
21 | ); 22 | } 23 | 24 | doChangeText = (e: React.FormEvent): void => { 25 | const target = e.target as HTMLInputElement; 26 | this.props.todosStore.updateNewTodoText(target.value); 27 | }; 28 | 29 | doAddTodo = (): void => { 30 | this.props.todosStore.addTodo(this.props.todosStore.newTodoText); 31 | }; 32 | 33 | doChangeView = (e: React.FormEvent): void => { 34 | const target = e.target as HTMLInputElement; 35 | const view = target.value as TodosView; 36 | this.props.todosStore.setView(view); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'normalize.css'; 2 | import 'purecss'; 3 | import './styles/main.css'; 4 | 5 | import createMobxStores from './main/createMobxStores'; 6 | import createHistory from './main/createHistory'; 7 | import createRoutes from './main/createRoutes'; 8 | import * as React from 'react'; 9 | import * as ReactDOM from 'react-dom'; 10 | import {Router} from 'react-router'; 11 | import {Provider} from 'mobx-react'; 12 | import {useStrict} from 'mobx'; 13 | 14 | // In a real application, I would use `useStrict(true)`, 15 | // but some examples demonstrate the differences between using and not using 16 | // actions, so we need to keep it disabled to prevent those examples from throwing. 17 | useStrict(false); 18 | 19 | /** 20 | * Create the store and React Router history and routes. 21 | */ 22 | const history = createHistory(); 23 | const stores = createMobxStores(history); 24 | const routes = createRoutes(); 25 | 26 | /** 27 | * Mount the app. 28 | */ 29 | const wrapper = document.getElementById('app-wrapper'); 30 | if (wrapper) { 31 | ReactDOM.render( 32 | 33 | 34 | {routes} 35 | 36 | , 37 | wrapper 38 | ); 39 | } else { 40 | throw new Error('Unable to find app wrapper element'); 41 | } 42 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The Unlicense 2 | 3 | This is free and unencumbered software released into the public domain. 4 | 5 | Anyone is free to copy, modify, publish, use, compile, sell, or 6 | distribute this software, either in source code form or as a compiled 7 | binary, for any purpose, commercial or non-commercial, and by any 8 | means. 9 | 10 | In jurisdictions that recognize copyright laws, the author or authors 11 | of this software dedicate any and all copyright interest in the 12 | software to the public domain. We make this dedication for the benefit 13 | of the public at large and to the detriment of our heirs and 14 | successors. We intend this dedication to be an overt act of 15 | relinquishment in perpetuity of all present and future rights to this 16 | software under copyright law. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 22 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 23 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | For more information, please refer to -------------------------------------------------------------------------------- /src/pages/GranularRerendersPage/ItemList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer} from 'mobx-react'; 3 | import {ItemModel} from '../../stores/GranularRerendersStore'; 4 | import Item from './Item'; 5 | 6 | interface Props { 7 | items: ItemModel[]; 8 | onToggle(item: ItemModel): void; 9 | } 10 | 11 | @observer 12 | export default class ItemList extends React.Component { 13 | renderCount = -1; 14 | itemInstances: Dict = {}; 15 | 16 | render(): JSX.Element { 17 | const {items, onToggle} = this.props; 18 | 19 | // This is a hack to demonstrate some MobX behavior. 20 | // In normal code render functions should be kept free of side-effects. 21 | this.renderCount++; 22 | 23 | return ( 24 |
25 | Item list - {this.renderCount} rerenders 26 | {items.map((item: ItemModel): JSX.Element => { 27 | return ( 28 | this.itemInstances[item.id] = c} 29 | item={item} onToggle={onToggle} 30 | /> 31 | ); 32 | })} 33 |
34 | ); 35 | } 36 | 37 | resetRenderCount(): void { 38 | this.renderCount = -1; 39 | 40 | // Also reset each item's render count. 41 | for (const id in this.itemInstances) { 42 | if (this.itemInstances[id]) { // may have been unmounted 43 | this.itemInstances[id].resetRenderCount(); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import RouterStore from './stores/RouterStore'; 2 | import BasicUsageStore from './stores/BasicUsageStore'; 3 | import ComputedPropertiesStore from './stores/ComputedPropertiesStore'; 4 | import EfficientNestedRerendersStore from './stores/EfficientNestedRerendersStore'; 5 | import GranularRerendersStore from './stores/GranularRerendersStore'; 6 | import DynamicDependenciesStore from './stores/DynamicDependenciesStore'; 7 | import BatchedMutationsStore from './stores/BatchedMutationsStore'; 8 | import TodosStore from './stores/TodosStore'; 9 | 10 | export type ExperimentName = 'basic-usage' | 'computed-properties' 11 | | 'granular-rerenders' | 'dynamic-dependencies' | 'batched-mutations' 12 | | 'efficient-nested-rerenders' | 'todos'; 13 | 14 | // TODO with TypeScript 2.0 we could use an enum with string literal values 15 | // to generate this automatically. 16 | export const experimentNames: ExperimentName[] = [ 17 | 'basic-usage', 'computed-properties', 'granular-rerenders', 'dynamic-dependencies', 18 | 'efficient-nested-rerenders', 'batched-mutations', 'todos', 19 | ]; 20 | 21 | export interface Stores { 22 | routerStore: RouterStore; 23 | basicUsageStore: BasicUsageStore; 24 | computedPropertiesStore: ComputedPropertiesStore; 25 | efficientNestedRerendersStore: EfficientNestedRerendersStore; 26 | granularRerendersStore: GranularRerendersStore; 27 | dynamicDependenciesStore: DynamicDependenciesStore; 28 | batchedMutationsStore: BatchedMutationsStore; 29 | todosStore: TodosStore; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Link/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Link as RRLink} from 'react-router'; 3 | import {observer, inject} from 'mobx-react'; 4 | import RouterStore from '../../stores/RouterStore'; 5 | import * as omit from 'lodash/omit'; 6 | import * as assign from 'lodash/assign'; 7 | import {Stores} from '../../types'; 8 | 9 | import './style.css'; 10 | 11 | interface SelectedStores { 12 | // The store prop is provided by the `observer` decorator. 13 | // The component reads `routerStore.path` in its render function to make the component 14 | // re-render on a route change. Without this the component does not update its `active` class. 15 | store?: RouterStore; 16 | } 17 | 18 | interface Props extends SelectedStores, ReactRouter.LinkProps {} 19 | 20 | @inject((stores: Stores, nextProps: Props): Props => { 21 | return assign({store: stores.routerStore}, nextProps); 22 | }) 23 | @observer 24 | export default class Link extends React.Component { 25 | render(): JSX.Element { 26 | const {store} = this.props; 27 | 28 | // Read the router store's observable path so MobX knows to re-render on route changes, 29 | // otherwise the `active` class on links never gets updated. 30 | store!.path; // tslint:disable-line:no-unused-expression 31 | 32 | // Remove the `store` prop since it shouldn't be passed to the link. 33 | const finalProps: any = omit(this.props, 'store'); 34 | 35 | return ( 36 | 37 | {this.props.children} 38 | 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/EfficientNestedRerendersPage/CounterDisplay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import EfficientNestedRerendersStore from '../../stores/EfficientNestedRerendersStore'; 4 | import {Stores} from '../../types'; 5 | import * as assign from 'lodash/assign'; 6 | 7 | interface SelectedStores { 8 | store?: EfficientNestedRerendersStore; 9 | } 10 | 11 | interface Props extends SelectedStores { 12 | counter: number; 13 | renderCountIndex: number; // hack, see the store for why it's being done like this 14 | children?: React.ReactNode; // for the nested demo we render some of these inside each other 15 | } 16 | 17 | @inject((stores: Stores, nextProps: Props): Props => { 18 | return assign({store: stores.efficientNestedRerendersStore}, nextProps); 19 | }) 20 | @observer 21 | export default class CounterDisplay extends React.Component { 22 | render(): JSX.Element { 23 | const {store, counter, renderCountIndex} = this.props; 24 | 25 | // This is a hack to demonstrate some MobX behavior. 26 | // In normal code render functions should be kept free of side-effects. 27 | store!.renderCounts[renderCountIndex]++; 28 | 29 | return ( 30 |
31 |
counter from props: {counter}
32 |
counter from injected store: {store!.counter}
33 |
34 | {store!.renderCounts[renderCountIndex]} rerenders 35 |
36 | {this.props.children} 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/TodosPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import TodosStore from '../../stores/TodosStore'; 4 | import TodoModel from '../../models/TodoModel'; 5 | import Todo from './Todo'; 6 | import NewTodo from './NewTodo'; 7 | import TodoViewControls from './TodoViewControls'; 8 | import {Stores} from '../../types'; 9 | 10 | interface SelectedStores { 11 | store?: TodosStore; 12 | } 13 | 14 | interface Props extends SelectedStores {} 15 | 16 | @inject((stores: Stores): Props => ({store: stores.todosStore})) 17 | @observer 18 | export default class TodosPage extends React.Component { 19 | render(): JSX.Element { 20 | const {store} = this.props; 21 | return ( 22 |
23 |

24 | This experiment demonstrates a simple todo list. 25 | Some of the concepts introduced here include filtering arrays via computed properties, 26 | composing models within a store, performing actions both on the store and models, 27 | and defining second-order computed properties. 28 |

29 |
30 | {store!.completedCount}/{store!.todos.length} complete 31 |
32 | 33 | 34 | {store!.visibleTodos.map((todo: TodoModel): JSX.Element => { 35 | return ; 36 | })} 37 |
38 | ); 39 | } 40 | 41 | doRemoveTodo = (todo: TodoModel): void => { 42 | this.props.store!.removeTodo(todo); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/main/createRoutes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Route, IndexRoute} from 'react-router'; 3 | import App from '../pages/App'; 4 | import IndexPage from '../pages/IndexPage'; 5 | import BasicUsagePage from '../pages/BasicUsagePage'; 6 | import ComputedPropertiesPage from '../pages/ComputedPropertiesPage'; 7 | import EfficientNestedRerendersPage from '../pages/EfficientNestedRerendersPage'; 8 | import GranularRerendersPage from '../pages/GranularRerendersPage'; 9 | import DynamicDependenciesPage from '../pages/DynamicDependenciesPage'; 10 | import BatchedMutationsPage from '../pages/BatchedMutationsPage'; 11 | import TodosPage from '../pages/TodosPage'; 12 | import {experimentNames, ExperimentName} from '../types'; 13 | 14 | /** 15 | * Creates the React Router routes for the entire app. 16 | */ 17 | export default function createRoutes(): JSX.Element { 18 | return ( 19 | 20 | 21 | {experimentNames.map((name: ExperimentName): JSX.Element => { 22 | return ; 23 | })} 24 | 25 | ); 26 | } 27 | 28 | export function getExperimentPage(name: ExperimentName): React.ComponentClass { 29 | switch (name) { 30 | case 'basic-usage': return BasicUsagePage; 31 | case 'computed-properties': return ComputedPropertiesPage; 32 | case 'efficient-nested-rerenders': return EfficientNestedRerendersPage; 33 | case 'granular-rerenders': return GranularRerendersPage; 34 | case 'dynamic-dependencies': return DynamicDependenciesPage; 35 | case 'batched-mutations': return BatchedMutationsPage; 36 | case 'todos': return TodosPage; 37 | default: throw new Error(`Unknown experiment name "${name}"`); 38 | } 39 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-mobx-typescript-experiments", 3 | "version": "0.1.9", 4 | "description": "experiments with MobX in a React application written with TypeScript", 5 | "homepage": "https://ryanatkn.github.io/react-mobx-typescript-experiments", 6 | "license": "Unlicense", 7 | "author": { 8 | "name": "Ryan Atkinson", 9 | "email": "mail@ryanatkn.com", 10 | "url": "https://www.ryanatkn.com" 11 | }, 12 | "scripts": { 13 | "start": "webpack-dev-server --progress --colors --inline", 14 | "typecheck": "tsc --noEmit", 15 | "lint": "tslint -t verbose src/**/*.ts{,x}", 16 | "build": "NODE_ENV=production webpack --progress --colors", 17 | "preversion": "npm run typecheck && npm run lint", 18 | "version": "npm run build && git add .", 19 | "postversion": "git push && git push --tags" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+ssh://git@github.org/ryanatkn/react-mobx-typescript-experiments.git" 24 | }, 25 | "dependencies": { 26 | "lodash": "^4.6.1", 27 | "mobx": "^3.4.1", 28 | "mobx-react": "^4.3.5", 29 | "normalize.css": "^4.0.0", 30 | "purecss": "^0.6.0", 31 | "react": "^15.0.0", 32 | "react-dom": "^15.0.0", 33 | "react-router": "^2.0.1" 34 | }, 35 | "devDependencies": { 36 | "autoprefixer": "^6.3.6", 37 | "css-loader": "^0.23.1", 38 | "postcss-calc": "^5.2.1", 39 | "postcss-color-function": "^2.0.1", 40 | "postcss-import": "^8.1.2", 41 | "postcss-loader": "^0.8.2", 42 | "pre-commit": "^1.1.2", 43 | "precss": "^1.4.0", 44 | "style-loader": "^0.13.0", 45 | "ts-loader": "^0.8.1", 46 | "tslint": "^3.6.0", 47 | "tslint-config-strictish": "ryanatkn/tslint-config-strictish#v3.12.0", 48 | "tslint-loader": "^2.1.3", 49 | "typescript": "^2.0.0", 50 | "webpack": "^1.12.14", 51 | "webpack-dev-server": "^1.14.1" 52 | }, 53 | "pre-commit": [ 54 | "lint", 55 | "typecheck" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/stores/TodosStore.ts: -------------------------------------------------------------------------------- 1 | import TodoModel from '../models/TodoModel'; 2 | import {observable, computed, action} from 'mobx'; 3 | 4 | const DEFAULT_TODOS = [ 5 | new TodoModel('bake pie'), 6 | new TodoModel('clean room'), 7 | new TodoModel('eat pie'), 8 | ]; 9 | 10 | export type TodosView = 'all' | 'completed' | 'pending'; 11 | 12 | // TODO check if the TypeScript nightly has string literals for enums yet 13 | export const views: TodosView[] = ['all', 'completed', 'pending']; 14 | 15 | export default class TodoStore { 16 | @observable todos: TodoModel[] = DEFAULT_TODOS; 17 | @observable newTodoText = ''; 18 | @observable view: TodosView = 'all'; 19 | 20 | @computed get completedTodos(): TodoModel[] { 21 | return this.todos.filter((todo) => todo.isComplete); 22 | } 23 | 24 | @computed get pendingTodos(): TodoModel[] { 25 | return this.todos.filter((todo) => !todo.isComplete); 26 | } 27 | 28 | // An example of a second-order computed property. 29 | @computed get completedCount(): number { 30 | return this.completedTodos.length; 31 | } 32 | 33 | // Another example of a second-order computed property. 34 | @computed get visibleTodos(): TodoModel[] { 35 | switch (this.view) { 36 | case 'all': return this.todos; 37 | case 'completed': return this.completedTodos; 38 | case 'pending': return this.pendingTodos; 39 | default: throw new Error('type is `never` here, but have to return or throw'); // TODO 40 | } 41 | } 42 | 43 | @action addTodo = (text: string): void => { 44 | if (!text) { 45 | return; 46 | } 47 | this.todos.push(new TodoModel(text)); 48 | this.newTodoText = ''; 49 | }; 50 | 51 | @action removeTodo = (todo: TodoModel): void => { 52 | (this.todos as any).remove(todo); // TODO type is unfortunately array instead of MobX array 53 | }; 54 | 55 | @action updateNewTodoText = (text: string): void => { 56 | this.newTodoText = text; 57 | }; 58 | 59 | @action setView = (view: TodosView): void => { 60 | this.view = view; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/stores/BatchedMutationsStore.ts: -------------------------------------------------------------------------------- 1 | import {observable, computed, action, reaction, autorun} from 'mobx'; 2 | 3 | export default class BatchedMutationstore { 4 | @observable counter = 0; 5 | 6 | // This is used to track the number of times `counter` is observed to change by reactions. 7 | counterReactionCount = 0; 8 | 9 | // This is used to track the number of times `counter` is observed to change by autorun. 10 | counterAutorunCount = 0; 11 | 12 | // This is used to track the number of times `counterSquared` is computed. 13 | counterSquaredComputeCount = 0; 14 | 15 | constructor() { 16 | // Watch `this.counter` in a reaction. 17 | // Run the reaction with `fireImmediately=true` 18 | // so the reaction and autorun counters are in sync, 19 | // which is probably less confusing to beginners. 20 | reaction(() => this.counter, () => this.counterReactionCount++, true); 21 | 22 | // Watch `this.counter` in an autorun. 23 | autorun(() => { 24 | // Read the observable counter so the autorun reacts to it. 25 | this.counter; // tslint:disable-line:no-unused-expression 26 | 27 | // Count the times the autorun runs. 28 | this.counterAutorunCount++; 29 | }); 30 | } 31 | 32 | @computed get counterSquared(): number { 33 | // This is a hack to track change counts. 34 | // Getters shouldn't mutate stuff in real code. 35 | this.counterSquaredComputeCount++; 36 | 37 | return this.counter * this.counter; 38 | } 39 | 40 | increment = (): void => { 41 | // Increment the counter. 42 | this.counter++; 43 | 44 | // Do some net-zero mutations to test how many times 45 | // the downstream reaction and computed properties run. 46 | this.counter--; 47 | this.counter--; 48 | this.counter--; 49 | this.counter++; 50 | this.counter++; 51 | this.counter++; 52 | }; 53 | 54 | @action incrementWithAction = this.increment; 55 | 56 | reset = (): void => { 57 | this.counterReactionCount = 0; 58 | this.counterAutorunCount = 0; 59 | this.counterSquaredComputeCount = 0; 60 | this.counter = 0; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const autoprefixer = require('autoprefixer'); 3 | const precss = require('precss'); 4 | const postcssCalc = require("postcss-calc"); 5 | const postcssColorFunction = require("postcss-color-function"); 6 | const postcssImport = require("postcss-import"); 7 | 8 | const NODE_ENV = process.env.NODE_ENV || 'development'; 9 | const __DEV__ = NODE_ENV === 'development'; 10 | const __PROD__ = NODE_ENV === 'production'; 11 | const __TEST__ = NODE_ENV === 'test'; 12 | 13 | module.exports = { 14 | entry: './src/index.tsx', 15 | output: { 16 | path: __dirname, 17 | filename: '/app.js' 18 | }, 19 | resolve: { 20 | extensions: ['', '.webpack.js', '.ts', '.tsx', '.js', '.json'], 21 | }, 22 | module: { 23 | preLoaders: [ 24 | {test: /\.tsx?$/, loader: 'tslint'}, 25 | ], 26 | loaders: [ 27 | {test: /\.tsx?$/, loader: 'ts'}, 28 | {test: /\.css$/, loader: "style!css!postcss"} 29 | ] 30 | }, 31 | plugins: getPlugins(), 32 | devtool: __DEV__ ? '#eval-source-map' : null, 33 | devServer: { 34 | port: 8080, 35 | contentBase: './', 36 | historyApiFallback: true, 37 | }, 38 | tslint: { 39 | emitErrors: false, 40 | failOnHint: false, 41 | formatter: 'verbose', 42 | }, 43 | postcss: function (wp) { 44 | return [ 45 | postcssImport({addDependencyTo: wp}), 46 | autoprefixer, 47 | precss, 48 | postcssCalc, 49 | postcssColorFunction, 50 | ]; 51 | } 52 | }; 53 | 54 | // Build plugins list imperatively because null values throw errors. 55 | function getPlugins() { 56 | const plugins = [ 57 | new webpack.DefinePlugin({ 58 | 'process.env': {NODE_ENV: JSON.stringify(NODE_ENV)}, 59 | __DEV__, 60 | __PROD__, 61 | __TEST__, 62 | }), 63 | ]; 64 | 65 | if (__PROD__) { 66 | plugins.push( 67 | new webpack.optimize.UglifyJsPlugin({ 68 | compress: { 69 | warnings: false, 70 | }, 71 | sourceMap: false, 72 | }) 73 | ); 74 | plugins.push(new webpack.optimize.OccurrenceOrderPlugin(true)); 75 | } 76 | 77 | return plugins; 78 | } -------------------------------------------------------------------------------- /src/pages/EfficientNestedRerendersPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import EfficientNestedRerendersStore from '../../stores/EfficientNestedRerendersStore'; 4 | import CounterDisplay from './CounterDisplay'; 5 | import {Stores} from '../../types'; 6 | 7 | interface SelectedStores { 8 | store?: EfficientNestedRerendersStore; 9 | } 10 | 11 | interface Props extends SelectedStores {} 12 | 13 | @inject((stores: Stores): Props => ({store: stores.efficientNestedRerendersStore})) 14 | @observer 15 | export default class EfficientNestedRerendersPage extends React.Component { 16 | render(): JSX.Element { 17 | const {store} = this.props; 18 | return ( 19 |
20 |

21 | MobX offers a strategy for side-loading data into components with mobx-react's `@inject`. 22 | This is similar to `@connect` in Redux. Both MobX and Redux use a top-level `Provider` 23 | that provides a store or stores in the React context. 24 | The decorators select these stores from the context in a 25 | higher-order component and inject them into the target component's props. 26 |

27 |

28 | This example passes data into the child components both through props 29 | and by injecting the store directly. Notice that the components rerender only 30 | a single time on a change even though their props and observed store state both change. 31 |

32 |
33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 | 43 |
44 |
45 | 48 |
49 |
50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/ComputedPropertiesPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import ComputedPropertiesStore from '../../stores/ComputedPropertiesStore'; 4 | import {Stores} from '../../types'; 5 | 6 | interface SelectedStores { 7 | store?: ComputedPropertiesStore; 8 | } 9 | 10 | interface Props extends SelectedStores {} 11 | 12 | @inject((stores: Stores): Props => ({store: stores.computedPropertiesStore})) 13 | @observer 14 | export default class ComputedPropertiesPage extends React.Component { 15 | render(): JSX.Element { 16 | const {store} = this.props; 17 | return ( 18 |
19 |

20 | MobX has a simple way to produce derived data via computed properties. 21 | Note that even though `fullName` is read from the store multiple times, 22 | it is computed only once each time the data changes. 23 |

24 |
25 |
26 | 29 | 32 | 35 | 38 |
39 | computed full name: {store!.fullName} 40 |
41 |
42 | computed full name again: {store!.fullName} 43 |
44 |
45 | computed full name yet again: {store!.fullName} 46 |
47 |
48 | number of times full name has been computed:{' '} 49 | {store!.fullNameComputeCount} 50 |
51 | 56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | doChangeFirstName = (e: React.FormEvent): void => { 63 | const target = e.target as HTMLInputElement; 64 | this.props.store!.updateFirstName(target.value); 65 | }; 66 | 67 | doChangeLastName = (e: React.FormEvent): void => { 68 | const target = e.target as HTMLInputElement; 69 | this.props.store!.updateLastName(target.value); 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/pages/DynamicDependenciesPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import DynamicDependenciesStore from '../../stores/DynamicDependenciesStore'; 4 | import {Stores} from '../../types'; 5 | 6 | interface SelectedStores { 7 | store?: DynamicDependenciesStore; 8 | } 9 | 10 | interface Props extends SelectedStores {} 11 | 12 | @inject((stores: Stores): Props => ({store: stores.dynamicDependenciesStore})) 13 | @observer 14 | export default class DynamicDependenciesPage extends React.Component { 15 | renderCount = -1; 16 | shouldReadCounter = true; 17 | 18 | render(): JSX.Element { 19 | const {store} = this.props; 20 | 21 | // This is a hack to demonstrate some MobX behavior. 22 | // In normal code render functions should be kept free of side-effects. 23 | this.renderCount++; 24 | if (this.renderCount === 3) { 25 | this.shouldReadCounter = false; 26 | } 27 | 28 | return ( 29 |
30 |

31 | MobX dynamically updates a component's dependencies on each render. 32 | This component stops reading the counter for the 4th and 5th clicks, 33 | so the render count should not increment for them. 34 | Not reading the counter simply means 35 | not accessing it on the store during the render function. 36 | It resumes watching the counter starting with the 6th click. 37 | Clicking the counter 10 times should cause only 8 renders, but the click 38 | count should be 10. 39 |

40 |
41 |
42 | render count: {this.renderCount} 43 |
44 |
45 | {this.shouldReadCounter 46 | ? click count: {store!.counter} 47 | : [stopped reading counter] 48 | } 49 |
50 |
51 | 54 |
55 |
56 |
57 | 60 |
61 |
62 | ); 63 | } 64 | 65 | doIncrement = (): void => { 66 | this.props.store!.increment(); 67 | if (this.props.store!.counter > 5) { 68 | this.shouldReadCounter = true; 69 | this.forceUpdate(); 70 | } 71 | }; 72 | 73 | doReset = (): void => { 74 | this.renderCount = -1; 75 | this.shouldReadCounter = true; 76 | this.props.store!.reset(); 77 | this.forceUpdate(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/pages/GranularRerendersPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import GranularRerendersStore from '../../stores/GranularRerendersStore'; 4 | import ItemList from './ItemList'; 5 | import {Stores} from '../../types'; 6 | 7 | interface SelectedStores { 8 | store?: GranularRerendersStore; 9 | } 10 | 11 | interface Props extends SelectedStores {} 12 | 13 | @inject((stores: Stores): Props => ({store: stores.granularRerendersStore})) 14 | @observer 15 | export default class GranularRerendersPage extends React.Component { 16 | itemListInstance: ItemList; 17 | 18 | render(): JSX.Element { 19 | const {store} = this.props; 20 | return ( 21 |
22 |

23 | MobX rerenders React components with granular precision when their observed data changes. 24 | When using React with a library like Redux that relies on immutable data, 25 | changing an item in a collection requires changing 26 | the references of both the item and its collection 27 | (and all other references up the state tree), 28 | causing a rerender of any components that use the collection. 29 |

30 |

31 | In this example, notice how toggling an item does not cause the list to rerender. 32 | This is because MobX automatically rerenders only the components 33 | which could actually be affected by the data changes. 34 |

35 |

36 | Note that it is possible, though more complex, to work around 37 | this behavior of immutable data as seen 38 | in this Redux TodoMVC project. 39 | The basic idea is that you store items in a centralized unrendered collection, 40 | create a separate collection of ids for rendered collections, 41 | and connect/select each item by id in the item component, 42 | so changing an item does not change a rendered collection, only the unrendered one. 43 |

44 |
45 | this.itemListInstance = c} 46 | onToggle={store!.toggleItem} 47 | /> 48 |
49 |
50 | 53 |
54 |
55 | 58 |
59 |
60 | ); 61 | } 62 | 63 | doReset = (): void => { 64 | // First reset the item list's render count, and all of its items' render counts. 65 | // It's easier to put this data on the component instances rather than the store!. 66 | this.itemListInstance.resetRenderCount(); 67 | 68 | this.props.store!.reset(); 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/BatchedMutationsPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {observer, inject} from 'mobx-react'; 3 | import BatchedMutationsStore from '../../stores/BatchedMutationsStore'; 4 | import {Stores} from '../../types'; 5 | 6 | interface SelectedStores { 7 | store?: BatchedMutationsStore; 8 | } 9 | 10 | interface Props extends SelectedStores {} 11 | 12 | @inject((stores: Stores): Props => ({store: stores.batchedMutationsStore})) 13 | @observer 14 | export default class BatchedMutationsPage extends React.Component { 15 | render(): JSX.Element { 16 | const {store} = this.props; 17 | return ( 18 |
19 |

20 | Actions in MobX are 21 | functions that mutate observable data. 22 | Using actions is optional unless `mobx.useStrict(true)` is used. 23 | Combined wih the MobX devtools, they achieve many of the same benefits 24 | that actions in Redux, Flux, and other event sourcing patterns provide. 25 |

26 |

27 | Mutations during an action are batched in a transaction, 28 | so downstream data that depends on the changes does not update 29 | until the action completes. 30 |

31 |

32 | MobX allows you to react to any observable data changes via `reaction` and `autorun`, 33 | which have similar purposes but different characteristics. 34 | In this example the observable counter is watched by a reaction and the number of times 35 | it appears to change is tracked as the "counter reaction count". 36 | It is also watched by an autorun which is counted as "counter autorun count". 37 |

38 |

39 | In this example, the counter is mutated many times on every click. 40 | It demonstrates how two types of downstream data, 41 | computed properties used in a React component and reactions/autorun, 42 | will run only a single time when the upstream data changes are wrapped in an action. 43 |

44 |
counter: {store!.counter}
45 |
counter squared: {store!.counterSquared}
46 |
47 | counter squared compute count:{' '} 48 | {store!.counterSquaredComputeCount} 49 |
50 |
51 | counter reaction count:{' '} 52 | {store!.counterReactionCount} 53 |
54 |
55 | counter autorun count:{' '} 56 | {store!.counterAutorunCount} 57 |
58 |
59 | 62 | 65 |
66 |
67 | 70 |
71 |
72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React MobX TypeScript Experiments 2 | 3 | Concept demos using MobX with React and TypeScript. 4 | 5 | [https://ryanatkn.github.io/react-mobx-typescript-experiments](https://ryanatkn.github.io/react-mobx-typescript-experiments) 6 | 7 | If you have any suggestions or questions, please open an issue! 8 | 9 | ## Motivation 10 | After having used the excellent Redux library extensively with React, 11 | I wanted to explore the approach offered by MobX. 12 | Whereas Redux embraces immutable data and renders React components 13 | from the top down starting with the connected components up the hierarchy, 14 | MobX embraces mutable, observable data, 15 | granularly re-rendering components automatically when data changes. 16 | 17 | MobX tracks the dependencies of each `@observer` component render function 18 | to intelligently re-render only when that data changes. 19 | It's a bit magical, which, depending on your perspective, may be good or bad or both. 20 | This is similar to the strategy used by Vue. 21 | 22 | ## Findings 23 | - I like MobX, and I will likely reach for it again for small projects and prototyping. 24 | For most nontrivial projects I will prefer Redux for the benefits of its immutability 25 | and declarative serializable replayable action stream, the latter of which is possible in MobX 26 | but not without tradeoffs. 27 | - There's less boilerplate than with Redux. This is largely due to not having the declarative action stream 28 | that Redux has by default, which is a major tradeoff. 29 | - Writing mutating operations is easier and less error-prone than transforming immutable data, 30 | and it's fully type safe, unlike Immutable.js and react-addons-update. 31 | - Composing computed properties feels natural and keeps everything nicely co-located. 32 | See [`src/stores/TodosStore`](https://github.com/ryanatkn/react-mobx-typescript-experiments/blob/gh-pages/src/stores/TodosStore.ts) for an example. 33 | - If I were writing a real application, 34 | I would make all data transformations explicit by enabling `mobx.useStrict(true)`, 35 | which requires that all observable-mutating operations be performed in an `@action`. 36 | This could be seen as additional boilerplate, 37 | because for example updating a simple property requires defining and calling an action function, 38 | but I like the explicit and trackable transformations 39 | that Redux, Flux, and other event sourcing patterns offer. 40 | The MobX devtools track and expose debug information for actions similar to Redux. 41 | Actions also ensure efficiency as demonstrated in 42 | the [Batched Mutations example](https://ryanatkn.github.io/react-mobx-typescript-experiments/#/batched-mutations). 43 | Some of these examples make mutations outside of actions for demonstration purposes, 44 | so `useStrict` is set to false. 45 | - After enabling the TypeScript 2.0 compiler flag `strictNullChecks`, 46 | I had to add the postfix `!` operator whenever components access the MobX stores, 47 | because all MobX `InjectedStores` interfaces mark the selected stores as optional 48 | to allow the components to be instantiated by parent components. 49 | This is a significant annoyance that I'll try to find a better solution for. 50 | 51 | ## Develop 52 | 53 | npm install 54 | npm install -g tsd 55 | tsd install 56 | npm start 57 | # browse to http://localhost:8080 58 | 59 | ## License 60 | 61 | public domain ([The Unlicense](license)) 62 | --------------------------------------------------------------------------------