Skip to content
Merged

Hooks #474

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Typescript/React/Redux Example Project [![Build Status](https://travis-ci.org/drewschrauf/typescript-react-redux.svg?branch=master)](https://travis-ci.org/drewschrauf/typescript-react-redux) [![Coverage Status](https://coveralls.io/repos/github/drewschrauf/typescript-react-redux/badge.svg?branch=master)](https://coveralls.io/github/drewschrauf/typescript-react-redux?branch=master)
# Typescript/React Example Project [![Build Status](https://travis-ci.org/drewschrauf/typescript-react-redux.svg?branch=master)](https://travis-ci.org/drewschrauf/typescript-react-redux) [![Coverage Status](https://coveralls.io/repos/github/drewschrauf/typescript-react-redux/badge.svg?branch=master)](https://coveralls.io/github/drewschrauf/typescript-react-redux?branch=master)

_**Now with 100% less Redux!**_

## What is this?

This is a basic example project using the following technologies to build a web app:

- Typescript
- React
- Redux
- React Router
- Webpack

Expand All @@ -14,3 +17,7 @@ And for testing:
- react-testing-library

[See the result](https://awesome-bose-57ba94.netlify.com)

## What happened to Redux?

It turns out that with the power of hooks, you really don't need Redux anymore. If you can't live without it, you'll find that the types used for the reducer and actions here work just as well for Redux.
6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@
"normalize.css": "8.0.1",
"react": "16.8.6",
"react-dom": "16.8.6",
"react-redux": "7.1.0",
"react-router-dom": "5.0.1",
"redux": "4.0.1",
"redux-thunk": "2.3.0",
"styled-components": "4.3.2"
},
"devDependencies": {
Expand All @@ -38,9 +35,8 @@
"@testing-library/react": "8.0.4",
"@types/jest": "24.0.15",
"@types/lodash": "4.14.135",
"@types/react": "16.8.19",
"@types/react": "16.8.23",
"@types/react-dom": "16.8.4",
"@types/react-redux": "7.1.1",
"@types/react-router-dom": "4.3.4",
"@types/styled-components": "4.1.16",
"@typescript-eslint/parser": "1.11.0",
Expand Down
11 changes: 5 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React, { Suspense, lazy } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import styled from 'styled-components';

import { CounterProvider } from '@/state/useCounter';
import Navigation from '@/components/Navigation';
import Spinner from '@/components/Spinner';
import store from '@/store';

const PageWrapper = styled.div`
width: 100%;
Expand All @@ -19,8 +18,8 @@ const AboutPage = lazy(() => import('@/pages/About'));
const MissingPage = lazy(() => import('@/pages/Missing'));

const App = () => (
<Router>
<Provider store={store()}>
<CounterProvider>
<Router>
<PageWrapper>
<Navigation />
<Suspense fallback={<Spinner />}>
Expand All @@ -32,7 +31,7 @@ const App = () => (
</Switch>
</Suspense>
</PageWrapper>
</Provider>
</Router>
</Router>
</CounterProvider>
);
export default App;
5 changes: 2 additions & 3 deletions src/__tests__/Counter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
/* eslint-disable no-console */
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import set from 'lodash/fp/set';

import Counter from '@/pages/Counter';
import store from '@/store';
import { CounterProvider } from '@/state/useCounter';

const DEFAULT_PROPS = { match: { params: {} } };

jest.useFakeTimers();

const renderWithProvider = (element: React.ReactNode) => {
return render(<Provider store={store()}>{element}</Provider>);
return render(<CounterProvider>{element}</CounterProvider>);
};

describe('counter', () => {
Expand Down
16 changes: 6 additions & 10 deletions src/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import styled from 'styled-components';

import { State, Dispatch } from '@/store';
import { decrementBy, delayedIncrementBy, incrementBy } from '@/store/actions/counter';
import useCounter from '@/state/useCounter';

interface CounterProps {
/** The amount to increment or decrement the counter by on each click */
Expand Down Expand Up @@ -33,15 +31,13 @@ const Button = styled.button`
* for modifying it.
*/
const Counter = ({ amount }: CounterProps) => {
const count = useSelector((state: State) => state.counter.count);
const pending = useSelector((state: State) => state.counter.pending);
const dispatch = useDispatch<Dispatch>();
const [state, actions] = useCounter();
return (
<Wrapper>
<h1>Count {count}</h1>
<Button onClick={() => dispatch(incrementBy({ amount }))}>Increment by {amount}</Button>
<Button onClick={() => dispatch(decrementBy({ amount }))}>Decrement by {amount}</Button>
<Button disabled={pending} onClick={() => dispatch(delayedIncrementBy({ amount }))}>
<h1>Count {state.count}</h1>
<Button onClick={() => actions.incrementBy(amount)}>Increment by {amount}</Button>
<Button onClick={() => actions.decrementBy(amount)}>Decrement by {amount}</Button>
<Button disabled={state.pending} onClick={() => actions.delayedIncrementBy(amount)}>
Delayed increment by {amount}
</Button>
</Wrapper>
Expand Down
39 changes: 39 additions & 0 deletions src/state/useCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createAction, isType, createHook } from './util';

const incrementBy = createAction<{ amount: number }>('INCREMENT');
const decrementBy = createAction<{ amount: number }>('DECREMENT');
const beginDelayedIncrement = createAction<{}>('BEGIN_DELAYED_INCREMENT');
const completeDelayedIncrement = createAction<{ amount: number }>('COMPLETE_DELAYED_INCREMENT');

const { Provider, hook } = createHook(
{ count: 0, pending: false },
(state, action) => {
if (isType(action, incrementBy)) {
return { ...state, count: state.count + action.payload.amount };
}
if (isType(action, decrementBy)) {
return { ...state, count: state.count - action.payload.amount };
}
if (isType(action, beginDelayedIncrement)) {
return { ...state, pending: true };
}
/* istanbul ignore else */
if (isType(action, completeDelayedIncrement)) {
return { ...state, pending: false, count: state.count + action.payload.amount };
}
/* istanbul ignore next */
return state;
},
dispatch => ({
incrementBy: (amount: number) => dispatch(incrementBy({ amount })),
decrementBy: (amount: number) => dispatch(decrementBy({ amount })),
delayedIncrementBy: async (amount: number) => {
dispatch(beginDelayedIncrement({}));
await new Promise(resolve => setTimeout(resolve, 500));
dispatch(completeDelayedIncrement({ amount }));
},
}),
);
Provider.displayName = 'CounterProvider';
export const CounterProvider = Provider;
export default hook;
55 changes: 55 additions & 0 deletions src/state/util.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';

export interface Action<T extends {}> {
/** Action type */
readonly type: string;
/** Action payload */
readonly payload: T;
}

interface ActionCreator<T extends {}> {
/** Type of created action */
readonly type: string;
/** Given a payload, create an action */
(payload: T): Action<T>;
}

export const createAction = <T extends {}>(type: string): ActionCreator<T> =>
Object.assign((payload: T) => ({ type, payload }), {
type,
});

export const isType = <T extends {}>(
action: Action<{}>,
actionCreator: ActionCreator<T>,
): action is Action<T> => action.type === actionCreator.type;

export const createHook = <State, Actions>(
initialState: State,
reducer: (state: State, action: Action<any>) => State,
bindActions: (dispatch: React.Dispatch<Action<any>>) => Actions,
) => {
type ContextValue = [State, Actions];

const Context = React.createContext<ContextValue>(undefined as any);

const Provider: React.FC = <T extends {}>(props: T) => {
const [currentState, dispatch] = React.useReducer(reducer, initialState);
const value = React.useMemo<ContextValue>(() => [currentState, bindActions(dispatch)], [
currentState,
dispatch,
]);
return <Context.Provider value={value} {...props} />;
};

const hook = () => {
const context = React.useContext(Context);
/* istanbul ignore if */
if (!context) {
throw new Error('hook must be used within the corresponding provider');
}
return context;
};

return { Provider, hook };
};
8 changes: 0 additions & 8 deletions src/store/actions/ActionType.ts

This file was deleted.

26 changes: 0 additions & 26 deletions src/store/actions/__tests__/counter.test.ts

This file was deleted.

22 changes: 0 additions & 22 deletions src/store/actions/counter.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/store/actions/util.ts

This file was deleted.

20 changes: 0 additions & 20 deletions src/store/index.ts

This file was deleted.

42 changes: 0 additions & 42 deletions src/store/reducers/__tests__/counter.test.ts

This file was deleted.

Loading