├── .editorconfig ├── .gitignore ├── README.md ├── jest.config.js ├── misc └── screencap.gif ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ ├── bindings.test.tsx │ └── testUtils.ts ├── actions │ ├── __tests__ │ │ └── actions.test.ts │ └── actions.ts ├── bindings.tsx ├── components │ ├── App.tsx │ ├── MessageForm.tsx │ ├── MessageList.tsx │ ├── Status.tsx │ └── __tests__ │ │ ├── MessageForm.test.tsx │ │ ├── MessageList.test.tsx │ │ └── Status.test.tsx ├── index.css ├── index.html ├── index.tsx ├── reducers │ ├── __tests__ │ │ └── rootReducer.test.ts │ └── rootReducer.ts └── state │ └── index.ts ├── testSupport └── setupEnzyme.js ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | # Matches multiple files with brace expansion notation 9 | # Set default charset 10 | [*.{js,ts,tsx,html,css}] 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .nyc_output 5 | .DS_Store 6 | .rpt2_cache 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roll Your Own Redux 2 | 3 | ![Screencap of the app](https://raw.githubusercontent.com/jamesseanwright/roll-your-own-redux/master/misc/screencap.gif) 4 | 5 | A proof-of-concept app demonstrating how one can implement Redux with [React Hooks](https://reactjs.org/docs/hooks-intro.html) and the [context API](https://reactjs.org/docs/context.html). 6 | 7 | ## The App 8 | 9 | The app is a straightforward React app that renders user-submitted messages, as well as displaying quotes from the [Ron Swanson Quotes](https://github.com/jamesseanwright/ron-swanson-quotes) API. 10 | 11 | ## Layout 12 | 13 | Given the small size of the codebase, the app's source directory `src` houses four directories: 14 | 15 | * `actions` - the action types and action creators dispatched by the app 16 | * `components` - the presentational and connected components, both of which are typically specified together in each module 17 | * `reducers` - contains a sole reducer for computing the next state for a given action 18 | * `state` - type definitions and defaults for our shared state 19 | 20 | ### The Bindings Module 21 | 22 | The bindings module (`bindings.tsx`) effectively reimplements React Redux (`react-redux`), with implementations of: 23 | 24 | * the [`Provider` component](https://react-redux.js.org/api#provider) 25 | * the [`connect` function](https://react-redux.js.org/api#connect) 26 | 27 | ## Missing Features 28 | 29 | For simplicity's sake, our implementation takes a few liberties: 30 | 31 | * The bindings are built around our own `State` type 32 | * Middleware is not supported. Instead, [thunks](https://github.com/reduxjs/redux-thunk) are supported out of the box 33 | * `Provider` doesn't accept a store prop, instead taking a reducer function directly 34 | * `connect`'s `mergeProps` parameter is not implemented 35 | * Redux's `combineReducers` and React Redux's `connectAdvanced` are missing 36 | 37 | ## Running Locally 38 | 39 | To set up: 40 | 41 | 1. `git clone https://github.com/jamesseanwright/roll-your-own-redux.git` 42 | 2. `cd react-observable-state` 43 | 3. `npm i` 44 | 45 | Then you can run one of the following commands: 46 | 47 | * `npm run dev` - builds the project with [rollup.js](https://rollupjs.org/guide/en) and serves it from port 8080 48 | * `npm test` - runs the unit tests (append ` -- --watch` to launch Jest's watch mode) 49 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest" 6 | }, 7 | testRegex: "src(\/.*)?\/__tests__\/.*\.test\.tsx?$", 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 9 | setupTestFrameworkScriptFile: './testSupport/setupEnzyme', 10 | globals: { 11 | 'ts-jest': { 12 | tsConfig: './tsconfig.json', 13 | diagnostics: false, 14 | } 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /misc/screencap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesseanwright/roll-your-own-redux/8aa3d0493af4ae433b5b3ce767feaccc0a172223/misc/screencap.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roll-your-own-redux", 3 | "version": "0.0.1", 4 | "description": "A proof-of-concept app demonstrating how one can implement Redux with React Hooks and the context API", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "mkdir -p dist && rm -rf dist/* && cp src/index.html src/index.css dist && rollup -c", 9 | "start": "static -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}' dist", 10 | "dev": "npm run build && npm start" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/jamesseanwright/roll-your-own-redux.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "redux", 19 | "hooks", 20 | "context" 21 | ], 22 | "author": "James Wright ", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/jamesseanwright/roll-your-own-redux/issues" 26 | }, 27 | "homepage": "https://github.com/jamesseanwright/roll-your-own-redux#readme", 28 | "dependencies": { 29 | "react": "16.8.4", 30 | "react-dom": "16.8.4" 31 | }, 32 | "devDependencies": { 33 | "@types/enzyme": "3.1.15", 34 | "@types/jest": "23.3.10", 35 | "@types/react": "16.7.13", 36 | "@types/react-dom": "16.0.11", 37 | "enzyme": "3.8.0", 38 | "enzyme-adapter-react-16": "1.11.2", 39 | "jest": "23.6.0", 40 | "node-static": "0.7.11", 41 | "rollup": "0.67.4", 42 | "rollup-plugin-commonjs": "9.2.0", 43 | "rollup-plugin-node-globals": "1.4.0", 44 | "rollup-plugin-node-resolve": "4.0.0", 45 | "rollup-plugin-typescript2": "0.18.1", 46 | "ts-jest": "23.10.5", 47 | "tslint": "5.11.0", 48 | "typescript": "3.2.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescriptPlugin from 'rollup-plugin-typescript2'; 2 | import typescript from 'typescript'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import nodeGlobals from 'rollup-plugin-node-globals'; 6 | 7 | export default { 8 | input: 'src/index.tsx', 9 | output: { 10 | file: 'dist/index.js', 11 | format: 'iife', 12 | }, 13 | plugins: [ 14 | resolve(), 15 | commonjs({ 16 | namedExports: { 17 | 'node_modules/react-dom/index.js': [ 18 | 'render', 19 | ], 20 | 'node_modules/react/index.js': [ 21 | 'Component', 22 | 'PropTypes', 23 | 'Fragment', 24 | 'createElement', 25 | 'useEffect', 26 | 'useState', 27 | 'useReducer', 28 | 'createContext', 29 | ], 30 | }, 31 | }), 32 | nodeGlobals(), 33 | typescriptPlugin({ 34 | typescript, 35 | }), 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /src/__tests__/bindings.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { Provider, connect, Reducer, AugmentedDispatch } from '../bindings'; 4 | import { Action } from '../actions/actions'; 5 | import { State, defaultState } from '../state'; 6 | 7 | interface OwnProps { 8 | baz: string; 9 | onAsyncBazComplete?(): void; 10 | } 11 | 12 | interface StateProps { 13 | hasMessages: boolean; 14 | hasBaz: boolean; 15 | } 16 | 17 | interface DispatchProps { 18 | setBaz?(baz: boolean): void; 19 | setBazAsync?(baz: boolean): void; 20 | } 21 | 22 | const bazNoOp = () => undefined; 23 | 24 | const createSetBazAction = (payload: boolean) => ({ 25 | type: 'SET_BAZ', 26 | payload, 27 | }); 28 | 29 | const MyComponent: React.FC = ({ baz, setBaz, setBazAsync }) => 30 | ; 31 | 32 | /* Despite React.useReducer being an implementation detail, 33 | * React-Test-Renderer and Enzyme don't have full Hook support, 34 | * thus I'm injecting stubs and placing assertions upon them. 35 | * TODO: remove stubs and query props for updates when 36 | * /airbnb/enzyme/pull/2041 is merged and released. */ 37 | describe('bindings', () => { 38 | describe('Provider with connect', () => { 39 | it('should pass any outer props to the connected component', () => { 40 | const reducer = (s: State, a: Action) => s; 41 | const dispatch = (action: Action) => undefined; 42 | const useReducer = (r: Reducer, s: State) => [defaultState, dispatch] as [State, React.Dispatch]; 43 | const ConnectedComponent = connect<{}, {}, OwnProps>()(MyComponent); 44 | 45 | const Root = () => ( 46 | 51 | 52 | 53 | ); 54 | 55 | const renderedRoot = mount(); 56 | const renderedChild = renderedRoot.find(ConnectedComponent); 57 | 58 | expect(renderedChild.prop('baz')).toBe('qux'); 59 | }); 60 | 61 | it('should pass the props returned by mapStateToProps and mapDispatchToProps to the wrapped components', () => { 62 | const state = { 63 | ...defaultState, 64 | messages: [ 65 | 'foo!', 66 | ], 67 | }; 68 | 69 | const setBaz = () => undefined; 70 | const reducer = (s: State, a: Action) => s; 71 | const dispatch = (action: Action) => undefined; 72 | const useReducer = (r: Reducer, s: State) => [state, dispatch] as [State, React.Dispatch]; 73 | 74 | const mapStateToProps = (state: State, ownProps: OwnProps): StateProps => ({ 75 | hasMessages: !!state.messages.length, 76 | hasBaz: !!ownProps.baz, 77 | }); 78 | 79 | const mapDispatchToProps = (dispatch: React.Dispatch, ownProps: OwnProps): DispatchProps => ({ 80 | setBaz, 81 | }); 82 | 83 | const ConnectedComponent = connect( 84 | mapStateToProps, 85 | mapDispatchToProps, 86 | )(MyComponent); 87 | 88 | const Root = () => ( 89 | 94 | 95 | 96 | ); 97 | 98 | const renderedRoot = mount(); 99 | const renderedChild = renderedRoot.find(MyComponent); 100 | 101 | expect(renderedChild.prop('baz')).toBe('qux'); 102 | expect(renderedChild.prop('hasMessages')).toBe(true); 103 | expect(renderedChild.prop('hasBaz')).toBe(true); 104 | expect(renderedChild.prop('setBaz')).toBe(setBaz); 105 | }); 106 | 107 | it('should invoke the dispatch when a dispatch prop is called', () => { 108 | const reducer = (s: State, a: Action) => s; 109 | const dispatch = jest.fn(); 110 | const useReducer = (r: Reducer, s: State) => [defaultState, dispatch] as [State, React.Dispatch]; 111 | 112 | const mapStateToProps = (state: State, ownProps: OwnProps): StateProps => ({ 113 | hasMessages: !!state.messages.length, 114 | hasBaz: !!ownProps.baz, 115 | }); 116 | 117 | const mapDispatchToProps = (dispatch: React.Dispatch>, ownProps: OwnProps): DispatchProps => ({ 118 | setBaz(baz) { 119 | dispatch(createSetBazAction(baz)); 120 | }, 121 | }); 122 | 123 | const ConnectedComponent = connect<{}, DispatchProps, OwnProps>( 124 | undefined, 125 | mapDispatchToProps, 126 | )(MyComponent); 127 | 128 | const Root = () => ( 129 | 134 | 135 | 136 | ); 137 | 138 | const renderedRoot = mount(); 139 | const renderedChild = renderedRoot.find('button'); 140 | 141 | renderedChild.simulate('click'); 142 | 143 | expect(dispatch).toHaveBeenCalledTimes(1); 144 | expect(dispatch).toHaveBeenCalledWith(createSetBazAction(true)); 145 | }); 146 | 147 | it('should augment the passed dispatch to support thunks', () => { 148 | const reducer = (s: State, a: Action) => s; 149 | const dispatch = jest.fn(); 150 | const useReducer = (r: Reducer, s: State) => [defaultState, dispatch] as [State, React.Dispatch]; 151 | 152 | const setBazAsync = (baz: boolean) => 153 | (innerDispatch: React.Dispatch, state: State) => { 154 | innerDispatch(createSetBazAction(state.hasQuoteError)); 155 | innerDispatch(createSetBazAction(baz)); 156 | }; 157 | 158 | const mapDispatchToProps = (dispatch: AugmentedDispatch): DispatchProps => ({ 159 | setBazAsync: (baz: boolean) => { 160 | dispatch(setBazAsync(baz)); 161 | }, 162 | }); 163 | 164 | const ConnectedComponent = connect<{}, DispatchProps, OwnProps>( 165 | undefined, 166 | mapDispatchToProps, 167 | )(MyComponent); 168 | 169 | const Root = () => ( 170 | 175 | 176 | 177 | ); 178 | 179 | const renderedRoot = mount(); 180 | const renderedChild = renderedRoot.find('button'); 181 | 182 | renderedChild.simulate('click'); 183 | 184 | expect(dispatch).toHaveBeenCalledTimes(2); 185 | 186 | expect(dispatch.mock.calls).toEqual([ 187 | [createSetBazAction(false)], 188 | [createSetBazAction(true)], 189 | ]); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/__tests__/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { State } from '../state'; 2 | 3 | export type SetState = (s: TState) => TState; 4 | export type StateHook = [TState, SetState]; 5 | -------------------------------------------------------------------------------- /src/actions/__tests__/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_MESSAGE, 3 | SET_QUOTE_ERROR, 4 | SET_QUOTE_LOADING, 5 | isAddMessage, 6 | isSetQuoteError, 7 | isSetQuoteLoading, 8 | addRonSwansonQuote, 9 | addMessage, 10 | setQuoteLoading, 11 | setQuoteError, 12 | } from '../actions'; 13 | 14 | describe('actions', () => { 15 | describe('isAddMessage', () => { 16 | it('should return true when the action type is ADD_MESSAGE', () => { 17 | expect(isAddMessage(addMessage(''))).toBe(true); 18 | }); 19 | 20 | it('should return false when the action type is not ADD_MESSAGE', () => { 21 | expect(isAddMessage(setQuoteLoading())).toBe(false); 22 | }); 23 | }); 24 | 25 | describe('isSetQuoteLoading', () => { 26 | it('should return true when the action type is SET_QUOTE_LOADING', () => { 27 | expect(isSetQuoteLoading(setQuoteLoading())).toBe(true); 28 | }); 29 | 30 | it('should return false when the action type is not SET_QUOTE_LOADING', () => { 31 | expect(isSetQuoteLoading(addMessage(''))).toBe(false); 32 | }); 33 | }); 34 | 35 | describe('isSetQuoteError', () => { 36 | it('should return true when the action type is SET_QUOTE_ERROR', () => { 37 | expect(isSetQuoteError(setQuoteError())).toBe(true); 38 | }); 39 | 40 | it('should return false when the action type is not SET_QUOTE_ERROR', () => { 41 | expect(isSetQuoteError(addMessage(''))).toBe(false); 42 | }); 43 | }); 44 | 45 | describe('addRonSwansonQuote', () => { 46 | it('should dispatch the quote loading and add message actions when the fetch succeeds', async () => { 47 | const quote = 'Friends: one to three is sufficient.'; 48 | 49 | const fetch = jest.fn().mockImplementation(() => 50 | Promise.resolve({ 51 | json: () => [quote], 52 | }), 53 | ); 54 | 55 | const dispatch = jest.fn(); 56 | const thunk = addRonSwansonQuote(fetch); 57 | 58 | await thunk(dispatch); 59 | 60 | expect(dispatch.mock.calls).toEqual([ 61 | [setQuoteLoading()], 62 | [addMessage(quote)], 63 | ]); 64 | }); 65 | 66 | it('should dispatch the quote loading and quote error actions when the fetch fails', async () => { 67 | const fetch = jest.fn().mockImplementation(() => 68 | Promise.reject(new Error('no')), 69 | ); 70 | 71 | const dispatch = jest.fn(); 72 | const thunk = addRonSwansonQuote(fetch); 73 | 74 | try { 75 | await thunk(dispatch); 76 | throw new Error('Expected thunk to reject'); 77 | } catch { 78 | expect(dispatch.mock.calls).toEqual([ 79 | [setQuoteLoading()], 80 | [setQuoteError()], 81 | ]); 82 | } 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/actions/actions.ts: -------------------------------------------------------------------------------- 1 | export interface Action { 2 | type: string; 3 | payload: TPayload; 4 | } 5 | 6 | interface AddMessagePayload { 7 | message: string; 8 | } 9 | 10 | export const ADD_MESSAGE = 'ADD_MESSAGE'; 11 | export const SET_QUOTE_LOADING = 'SET_QUOTE_LOADING'; 12 | export const SET_QUOTE_ERROR = 'SET_QUOTE_ERROR'; 13 | 14 | const createActionTypeGuard = (type: string) => 15 | (action: Action<{}>): action is Action => 16 | action.type === type; 17 | 18 | export const isAddMessage = createActionTypeGuard(ADD_MESSAGE); 19 | export const isSetQuoteLoading = createActionTypeGuard(SET_QUOTE_LOADING); 20 | export const isSetQuoteError = createActionTypeGuard(SET_QUOTE_ERROR); 21 | 22 | const defaultPayload = {}; 23 | 24 | export const setQuoteLoading = () => ({ 25 | type: SET_QUOTE_LOADING, 26 | payload: defaultPayload, 27 | }); 28 | 29 | export const setQuoteError = () => ({ 30 | type: SET_QUOTE_ERROR, 31 | payload: defaultPayload, 32 | }); 33 | 34 | export const addMessage = (message: string) => ({ 35 | type: ADD_MESSAGE, 36 | payload: { 37 | message, 38 | }, 39 | }); 40 | 41 | export const addRonSwansonQuote = (fetch = window.fetch) => 42 | (dispatch: React.Dispatch) => { 43 | dispatch(setQuoteLoading()); 44 | 45 | return fetch('https://ron-swanson-quotes.herokuapp.com/v2/quotes') 46 | .then(res => res.json()) 47 | .then(([quote]: string[]) => 48 | dispatch(addMessage(quote)), 49 | ) 50 | .catch(() => dispatch(setQuoteError())); 51 | }; 52 | -------------------------------------------------------------------------------- /src/bindings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { State, defaultState } from './state'; 3 | import { Action } from './actions/actions'; 4 | 5 | export type Reducer = React.Reducer; 6 | 7 | interface ProviderProps { 8 | defaultState: State; // TODO: mention intentional hard-coding of State type in post 9 | reducer: Reducer; 10 | useReducer?: (r: Reducer, s: State) => [State, React.Dispatch]; 11 | } 12 | 13 | interface Context { 14 | state: State; 15 | dispatch: React.Dispatch; 16 | } 17 | 18 | export type Thunk = (dispatch: React.Dispatch, state: State) => void; 19 | export type AugmentedDispatch = React.Dispatch; 20 | type MapTo = (a: TArgA, b: TArgB) => TResult; 21 | 22 | const defaultDispatch = () => undefined; 23 | 24 | const StateContext = React.createContext({ 25 | state: defaultState, 26 | dispatch: defaultDispatch, 27 | }); 28 | 29 | /* Rather than go to the overhead of 30 | * implementing middleware, we'll abstract 31 | * dispatch to add thunk support for free 32 | * TODO: in article, don't augment dispatch initially */ 33 | const augmentDispatch = (dispatch: React.Dispatch, state: State) => 34 | (input: Thunk | Action) => 35 | input instanceof Function ? input(dispatch, state) : dispatch(input); 36 | 37 | export const Provider: React.FC = ({ 38 | reducer, 39 | children, 40 | useReducer = React.useReducer, // TODO: remove when RTR/Enzyme support hooks 41 | }) => { 42 | const [state, dispatch] = useReducer(reducer, defaultState); 43 | 44 | return ( 45 | 49 | {children} 50 | 51 | ); 52 | }; 53 | 54 | const withDefault = (mapTo?: MapTo): MapTo => 55 | (a: TArgA, b: TArgB) => mapTo ? mapTo(a, b) : {} as TResult; 56 | 57 | export const connect = ( 58 | mapStateToProps?: (state: State, ownProps: TOwnProps) => TStateProps, 59 | mapDispatchToProps?: (dispatch: AugmentedDispatch, ownProps: TOwnProps) => TDispatchProps, 60 | // For simplicity, we're omitting mergeProps for now 61 | ) => 62 | (Component: React.ComponentType) => ( 63 | (props: TOwnProps) => 64 | 65 | {({ state, dispatch }) => ( 66 | 71 | )} 72 | 73 | ); 74 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import MessageList from './MessageList'; 3 | import MessageForm from './MessageForm'; 4 | import Status from './Status'; 5 | import { Provider } from '../bindings'; 6 | import { defaultState } from '../state'; 7 | import rootReducer from '../reducers/rootReducer'; 8 | 9 | export default () => ( 10 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/MessageForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect, AugmentedDispatch } from '../bindings'; 3 | import { State } from '../state'; 4 | import { addMessage, addRonSwansonQuote } from '../actions/actions'; 5 | 6 | export type StateProps = Pick; 7 | 8 | export interface DispatchProps { 9 | addMessage(message: string): void; 10 | addRonSwansonQuote(): AugmentedDispatch; 11 | } 12 | 13 | export const createMessageForm = (useState = React.useState): React.FC => 14 | ({ isFormValid, hasQuoteError, isLoadingQuote, addMessage, addRonSwansonQuote }) => { 15 | const [message, setMessage] = useState(''); 16 | 17 | return ( 18 |
19 |

Add a Message

20 | 21 |
{ 24 | e.preventDefault(); 25 | addMessage(message); 26 | }} 27 | > 28 | setMessage(e.currentTarget.value)} 34 | /> 35 | 40 | 49 |
50 | 51 | {!isFormValid &&

Please enter a message!

} 52 | {hasQuoteError &&

Unable to retrieve Ron Swanson quote!

} 53 |
54 | ); 55 | }; 56 | 57 | const mapStateToProps = ({ isFormValid, hasQuoteError, isLoadingQuote }: StateProps) => ({ 58 | isFormValid, 59 | hasQuoteError, 60 | isLoadingQuote, 61 | }); 62 | 63 | const mapDispatchToProps = (dispatch: AugmentedDispatch) => ({ 64 | addMessage: (message: string) => dispatch(addMessage(message)), 65 | addRonSwansonQuote: () => dispatch(addRonSwansonQuote()), 66 | }); 67 | 68 | export default connect( 69 | mapStateToProps, 70 | mapDispatchToProps, 71 | )(createMessageForm()); 72 | -------------------------------------------------------------------------------- /src/components/MessageList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { State } from '../state'; 3 | import { connect } from '../bindings'; 4 | 5 | type StateProps = Pick; 6 | 7 | export const MessageList = ({ messages }: StateProps) => ( 8 |
    9 | {messages.map((message, i) =>
  • {message}
  • )} 10 |
11 | ); 12 | 13 | const mapStateToProps = ({ messages }: State) => ({ 14 | messages, 15 | }); 16 | 17 | export default connect(mapStateToProps)(MessageList); 18 | -------------------------------------------------------------------------------- /src/components/Status.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from '../bindings'; 3 | import { State } from '../state'; 4 | 5 | export const Status = ({ messages }: Pick) => ( 6 |

{messages.length} {messages.length === 1 ? 'message' : 'messages'}

7 | ); 8 | 9 | const mapStateToProps = ({ messages }: State) => ({ 10 | messages, 11 | }); 12 | 13 | export default connect(mapStateToProps)(Status); 14 | -------------------------------------------------------------------------------- /src/components/__tests__/MessageForm.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { createMessageForm, DispatchProps, StateProps } from '../MessageForm'; 4 | import { StateHook, SetState } from '../../__tests__/testUtils'; 5 | 6 | const ADD_QUOTE_BUTTON_SELECTOR = 'button[name="add-quote"]'; 7 | const FORM_INVALID_MESSAGE_SELECTOR = '.form-invalid-message'; 8 | const QUOTE_ERROR_MESSAGE_SELECTOR = '.quote-failure-message'; 9 | 10 | /* TODO: refactor state hook testing when 11 | * possible to assert props instead */ 12 | describe('MessageForm', () => { 13 | let defaultSetState: jest.Mock>; 14 | let defaultUseState: jest.Mock>; 15 | let addMessage: jest.Mock; 16 | let addRonSwansonQuote: jest.Mock; 17 | let MessageForm: React.ComponentType; 18 | 19 | beforeEach(() => { 20 | defaultSetState = jest.fn(state => state); 21 | defaultUseState = jest.fn>(initialState => [initialState, defaultSetState]); 22 | addMessage = jest.fn(); 23 | addRonSwansonQuote = jest.fn(); 24 | MessageForm = createMessageForm(defaultUseState); 25 | }); 26 | 27 | it('should render its initial state when no quote is loading, there`s no quote error, and the form is valid', () => { 28 | const rendered = shallow( 29 | , 36 | ); 37 | 38 | const quoteButton = rendered.find(ADD_QUOTE_BUTTON_SELECTOR); 39 | const formInvalidMessage = rendered.find(FORM_INVALID_MESSAGE_SELECTOR); 40 | const quoteFailedMessage = rendered.find(QUOTE_ERROR_MESSAGE_SELECTOR); 41 | 42 | expect(quoteButton.prop('disabled')).toBe(false); 43 | expect(formInvalidMessage.exists()).toBe(false); 44 | expect(quoteFailedMessage.exists()).toBe(false); 45 | }); 46 | 47 | it('should disable the add quote button when a quote is loading', () => { 48 | const rendered = shallow( 49 | , 56 | ); 57 | 58 | const quoteButton = rendered.find(ADD_QUOTE_BUTTON_SELECTOR); 59 | 60 | expect(quoteButton.prop('disabled')).toBe(true); 61 | }); 62 | 63 | it('should show the quote error message when a quote could not be loaded', () => { 64 | const rendered = shallow( 65 | , 72 | ); 73 | 74 | const errorMessage = rendered.find(QUOTE_ERROR_MESSAGE_SELECTOR); 75 | 76 | expect(errorMessage.exists()).toBe(true); 77 | }); 78 | 79 | it('should show the form error message it is invalid', () => { 80 | const rendered = shallow( 81 | , 88 | ); 89 | 90 | const errorMessage = rendered.find(FORM_INVALID_MESSAGE_SELECTOR); 91 | 92 | expect(errorMessage.exists()).toBe(true); 93 | }); 94 | 95 | it('should update the message in the state when the input changes value', () => { 96 | const rendered = shallow( 97 | , 104 | ); 105 | 106 | const input = rendered.find('input[name="message"]'); 107 | 108 | input.simulate('change', { 109 | currentTarget: { 110 | value: 'foo', 111 | }, 112 | }); 113 | 114 | expect(defaultSetState).toHaveBeenCalledTimes(1); 115 | expect(defaultSetState).toHaveBeenCalledWith('foo'); 116 | }); 117 | 118 | it('should dispatch the addMessage action when the form is submitted', () => { 119 | const useState = jest.fn>(() => ['my message', defaultSetState]); 120 | const MessageForm = createMessageForm(useState); 121 | 122 | const rendered = shallow( 123 | , 130 | ); 131 | 132 | const form = rendered.find('form[name="message-form"]'); 133 | 134 | const submitEvent = { 135 | preventDefault: jest.fn(), 136 | }; 137 | 138 | form.simulate('submit', submitEvent); 139 | 140 | expect(submitEvent.preventDefault).toHaveBeenCalledTimes(1); 141 | expect(addMessage).toHaveBeenCalledTimes(1); 142 | expect(addMessage).toHaveBeenCalledWith('my message'); 143 | }); 144 | 145 | it('should dispatch the addRonSwansonQuote action when the add quote button is clicked', () => { 146 | const rendered = shallow( 147 | , 154 | ); 155 | 156 | const button = rendered.find(ADD_QUOTE_BUTTON_SELECTOR); 157 | 158 | button.simulate('click'); 159 | 160 | expect(addRonSwansonQuote).toHaveBeenCalledTimes(1); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/components/__tests__/MessageList.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { MessageList } from '../MessageList'; 4 | 5 | describe('MessageList', () => { 6 | it('should render the messages passed via props', () => { 7 | const messages = ['foo', 'bar', 'baz']; 8 | const rendered = shallow(); 9 | 10 | messages.forEach((message, i) => { 11 | const messageElement = rendered.childAt(i); 12 | expect(messageElement.type()).toBe('li'); 13 | expect(messageElement.text()).toBe(message); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/__tests__/Status.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Status } from '../Status'; 4 | 5 | describe('Status', () => { 6 | it('should render the total number of messages', () => { 7 | const messages = ['foo', 'bar', 'baz']; 8 | const rendered = shallow(); 9 | 10 | expect(rendered.text()).toBe('3 messages'); 11 | }); 12 | 13 | it('should singularise `messages` when there is just one message in the array', () => { 14 | const messages = ['foo']; 15 | const rendered = shallow(); 16 | 17 | expect(rendered.text()).toBe('1 message'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 16px; 3 | } 4 | 5 | body { 6 | margin: 8px 16px; 7 | } 8 | 9 | *, input, button { 10 | font-family: sans-serif; 11 | box-sizing: content-box; 12 | } 13 | 14 | .form-input { 15 | font-size: 1rem; 16 | padding: 14px; 17 | margin-right: 7px; 18 | } 19 | 20 | .form-button { 21 | background: lightgreen; 22 | border: 0; 23 | padding: 16px 14px; 24 | } 25 | 26 | .form-button:disabled { 27 | background: lightgrey; 28 | } 29 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Roll Your Own Redux 6 | 7 | 8 | 9 | 10 |
11 |

Roll Your Own Redux

12 | 13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './components/App'; 4 | 5 | ReactDOM.render(, document.querySelector('#app')); 6 | -------------------------------------------------------------------------------- /src/reducers/__tests__/rootReducer.test.ts: -------------------------------------------------------------------------------- 1 | import rootReducer from '../rootReducer'; 2 | import { addMessage, setQuoteLoading, setQuoteError } from '../../actions/actions'; 3 | 4 | describe('root reducer', () => { 5 | it('should reset the loading and error properties and add a message if valid', () => { 6 | const message = 'hello!'; 7 | 8 | const previousState = { 9 | isLoadingQuote: true, 10 | hasQuoteError: true, 11 | isFormValid: false, 12 | messages: [ 13 | 'foo', 14 | ], 15 | }; 16 | 17 | const expectedState = { 18 | isLoadingQuote: false, 19 | hasQuoteError: false, 20 | isFormValid: true, 21 | messages: [ 22 | message, 23 | 'foo', 24 | ], 25 | }; 26 | 27 | const actualState = rootReducer(previousState, addMessage(message)); 28 | 29 | expect(actualState).toEqual(expectedState); 30 | }); 31 | 32 | it('should progress to the invalid state if the message is falsy', () => { 33 | const previousState = { 34 | isLoadingQuote: true, 35 | hasQuoteError: true, 36 | isFormValid: true, 37 | messages: [ 38 | 'foo', 39 | ], 40 | }; 41 | 42 | const expectedState = { 43 | isLoadingQuote: false, 44 | hasQuoteError: false, 45 | isFormValid: false, 46 | messages: [ 47 | 'foo', 48 | ], 49 | }; 50 | 51 | const actualState = rootReducer(previousState, addMessage('')); 52 | 53 | expect(actualState).toEqual(expectedState); 54 | }); 55 | 56 | it('should progress to the loading state when it receives a loading action', () => { 57 | const previousState = { 58 | isLoadingQuote: false, 59 | hasQuoteError: true, 60 | isFormValid: true, 61 | messages: [ 62 | 'foo', 63 | ], 64 | }; 65 | 66 | const expectedState = { 67 | isLoadingQuote: true, 68 | hasQuoteError: false, 69 | isFormValid: true, 70 | messages: [ 71 | 'foo', 72 | ], 73 | }; 74 | 75 | const actualState = rootReducer(previousState, setQuoteLoading()); 76 | 77 | expect(actualState).toEqual(expectedState); 78 | }); 79 | 80 | it('should progress to the error state when it receives an error action', () => { 81 | const previousState = { 82 | isLoadingQuote: true, 83 | hasQuoteError: false, 84 | isFormValid: true, 85 | messages: [ 86 | 'foo', 87 | ], 88 | }; 89 | 90 | const expectedState = { 91 | isLoadingQuote: false, 92 | hasQuoteError: true, 93 | isFormValid: true, 94 | messages: [ 95 | 'foo', 96 | ], 97 | }; 98 | 99 | const actualState = rootReducer(previousState, setQuoteError()); 100 | 101 | expect(actualState).toEqual(expectedState); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/reducers/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { State } from '../state'; 3 | import { isAddMessage, Action, isSetQuoteLoading, isSetQuoteError } from '../actions/actions'; 4 | 5 | const rootReducer: React.Reducer = (state, action) => { 6 | if (isAddMessage(action)) { 7 | const { message } = action.payload; 8 | 9 | return { 10 | ...state, 11 | isLoadingQuote: false, 12 | hasQuoteError: false, 13 | isFormValid: !!message, 14 | messages: [ 15 | ...(message ? [message] : []), 16 | ...state.messages, 17 | ], 18 | }; 19 | } 20 | 21 | if (isSetQuoteLoading(action)) { 22 | return { 23 | ...state, 24 | isLoadingQuote: true, 25 | hasQuoteError: false, 26 | }; 27 | } 28 | 29 | if (isSetQuoteError(action)) { 30 | return { 31 | ...state, 32 | isLoadingQuote: false, 33 | hasQuoteError: true, 34 | }; 35 | } 36 | 37 | return state; 38 | }; 39 | 40 | export default rootReducer; 41 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | messages: string[]; 3 | isFormValid: boolean; 4 | isLoadingQuote: boolean; 5 | hasQuoteError: boolean; 6 | } 7 | 8 | export const defaultState: State = { 9 | messages: [], 10 | isFormValid: true, 11 | isLoadingQuote: false, 12 | hasQuoteError: false, 13 | }; 14 | -------------------------------------------------------------------------------- /testSupport/setupEnzyme.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Enzyme = require('enzyme'); 4 | const Adapter = require('enzyme-adapter-react-16'); 5 | 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictNullChecks": true, 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "outDir": "dist", 8 | "target": "ES6", 9 | "module": "ES2015", 10 | "removeComments": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "rules": { 7 | "array-type": [true, "array"], 8 | "arrow-parens": [true, "ban-single-arg-parens"], 9 | "interface-name": [false], 10 | "member-ordering": [false], 11 | "no-empty": [false], 12 | "no-unused-expression": [false], 13 | "no-shadowed-variable": [false], 14 | "object-literal-sort-keys": [false], 15 | "indent": [true, "spaces", 2], 16 | "only-arrow-functions": [false], 17 | "ordered-imports": [false], 18 | "quotemark": [true, "single", "jsx-double"], 19 | "space-before-function-paren": [true, { "anonymous": "always" }], 20 | "variable-name": [true, "check-format", "allow-leading-underscore", "allow-pascal-case"] 21 | }, 22 | "rulesDirectory": [] 23 | } 24 | --------------------------------------------------------------------------------