Skip to content

Latest commit

 

History

History
1855 lines (1405 loc) · 56.6 KB

README.md

File metadata and controls

1855 lines (1405 loc) · 56.6 KB

React & Redux in TypeScript - Static Typing Guide

"This guide is a living compendium documenting the most important patterns and recipes on how to use React (and its Ecosystem) in a functional style using TypeScript. It will help you make your code completely type-safe while focusing on inferring the types from implementation so there is less noise coming from excessive type annotations and it's easier to write and maintain correct types in the long run."

Join the chat at https://gitter.im/react-redux-typescript-guide/Lobby

Found it useful? Want more updates? Show your support by giving a ⭐

🎉 Now updated to be compatible with TypeScript v3.1.6 🎉

💻 Reference implementation of Todo-App with typesafe-actions: https://codesandbox.io/s/github/piotrwitek/typesafe-actions-todo-app 💻

Goals

  • Complete type safety (with --strict flag) without losing type information downstream through all the layers of our application (e.g. no type assertions or hacking with any type)
  • Make type annotations concise by eliminating redundancy in types using advanced TypeScript Language features like Type Inference and Control flow analysis
  • Reduce repetition and complexity of types with TypeScript focused complementary libraries

Complementary Libraries

  • utility-types - Collection of generic types for TypeScript, complementing built-in mapped types and aliases - think lodash for reusable types.
  • typesafe-actions - Typesafe utilities for "action-creators" in Redux / Flux Architecture

Playground Project

Build Status

You should check out Playground Project located in the /playground folder. It is a source of all the code examples found in the guide. They are all tested with the most recent version of TypeScript and 3rd party type-definitions (like @types/react or @types/react-redux) to ensure the examples are up-to-date and not broken with updated definitions.

Playground was created in such a way that you can simply clone the repository locally and immediately play around on your own. It will help you to learn all the examples from this guide in a real project environment without the need to create some complicated environment setup by yourself.

Contributing Guide

We are open for contributions. If you're planning to contribute please make sure to read the contributing guide: CONTRIBUTING.md

Sponsor

This is an independent open-source project created by people investing their free time for the benefit of our community.

If you are using it please consider donating as this will guarantee the project will be updated and maintained in the long run.

Issues can be funded by anyone and the money will be transparently distributed to the contributors handling a particular issue.

Let's fund issues in this repository


Table of Contents


Introduction

Type-Definitions for React & Redux

npm i -D @types/react @types/react-dom @types/react-redux

"react" - @types/react
"react-dom" - @types/react-dom
"redux" - (types included with npm package)*
"react-redux" - @types/react-redux

*NB: Guide is based on types for Redux >= v4.x.x. To make it work with Redux v3.x.x please refer to this config)

⇧ back to top


React - Type-Definitions Cheatsheet

React.FunctionComponent<P> or React.FC<P>

Type representing a functional component

const MyComponent: React.FC<Props> = ...

React.Component<P, S>

Type representing a class component

class MyComponent extends React.Component<Props, State> { ...

React.ComponentProps<typeof Component>

Gets type of Component Props, so you don't need to export Props from your component ever! (Works for both FC and Class components)

type MyComponentProps = React.ComponentProps<typeof MyComponent>;

React.ComponentType<P>

Type representing union type of (React.FC | React.Component)

const withState = <P extends WrappedComponentProps>(
  WrappedComponent: React.ComponentType<P>,
) => { ...

React.ReactElement<P> or JSX.Element

Type representing a concept of React Element - representation of a native DOM component (e.g. <div />), or a user-defined composite component (e.g. <MyComponent />)

const elementOnly: React.ReactElement = <div /> || <MyComponent />;

React.ReactNode

Type representing any possible type of React node (basically ReactElement (including Fragments and Portals) + primitive JS types)

const elementOrPrimitive: React.ReactNode = 'string' || 0 || false || null || undefined || <div /> || <MyComponent />;
const Component = ({ children: React.ReactNode }) => ...

React.CSSProperties

Type representing style object in JSX (usefull for css-in-js styles)

const styles: React.CSSProperties = { flexDirection: 'row', ...
const element = <div style={styles} ...

React.ReactEventHandler<E>

Type representing generic event handler

const handleChange: React.ReactEventHandler<HTMLInputElement> = (ev) => { ... } 

<input onChange={handleChange} ... />

React.MouseEvent<E> | React.KeyboardEvent<E> | React.TouchEvent<E> etc...

Type representing more specific event handler

const handleChange = (ev: React.MouseEvent<HTMLDivElement>) => { ... }

<div onMouseMove={handleChange} ... />

⇧ back to top


React - Typing Patterns

Function Components - FC

- FC counter

import * as React from 'react';

type Props = {
  label: string;
  count: number;
  onIncrement: () => any;
};

export const FCCounter: React.FC<Props> = props => {
  const { label, count, onIncrement } = props;

  const handleIncrement = () => {
    onIncrement();
  };

  return (
    <div>
      <span>
        {label}: {count}
      </span>
      <button type="button" onClick={handleIncrement}>
        {`Increment`}
      </button>
    </div>
  );
};

⟩⟩⟩ demo

⇧ back to top

- spread attributes link

import * as React from 'react';

type Props = {
  className?: string;
  style?: React.CSSProperties;
};

export const FCSpreadAttributes: React.FC<Props> = props => {
  const { children, ...restProps } = props;

  return <div {...restProps}>{children}</div>;
};

⟩⟩⟩ demo

⇧ back to top


Class Components

- class counter

import * as React from 'react';

type Props = {
  label: string;
};

type State = {
  count: number;
};

export class ClassCounter extends React.Component<Props, State> {
  readonly state: State = {
    count: 0,
  };

  handleIncrement = () => {
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    const { handleIncrement } = this;
    const { label } = this.props;
    const { count } = this.state;

    return (
      <div>
        <span>
          {label}: {count}
        </span>
        <button type="button" onClick={handleIncrement}>
          {`Increment`}
        </button>
      </div>
    );
  }
}

⟩⟩⟩ demo

⇧ back to top

- with default props

import * as React from 'react';

type Props = {
  label: string;
  initialCount: number;
};

type State = {
  count: number;
};

export class ClassCounterWithDefaultProps extends React.Component<
  Props,
  State
> {
  static defaultProps = {
    initialCount: 0,
  };

  readonly state: State = {
    count: this.props.initialCount,
  };

  componentWillReceiveProps({ initialCount }: Props) {
    if (initialCount != null && initialCount !== this.props.initialCount) {
      this.setState({ count: initialCount });
    }
  }

  handleIncrement = () => {
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    const { handleIncrement } = this;
    const { label } = this.props;
    const { count } = this.state;

    return (
      <div>
        <span>
          {label}: {count}
        </span>
        <button type="button" onClick={handleIncrement}>
          {`Increment`}
        </button>
      </div>
    );
  }
}

⟩⟩⟩ demo

⇧ back to top


Generic Components

  • easily create typed component variations and reuse common logic
  • common use case is a generic list components

- generic list

import * as React from 'react';

export interface GenericListProps<T> {
  items: T[];
  itemRenderer: (item: T) => JSX.Element;
}

export class GenericList<T> extends React.Component<GenericListProps<T>, {}> {
  render() {
    const { items, itemRenderer } = this.props;

    return (
      <div>
        {items.map(itemRenderer)}
      </div>
    );
  }
}

⟩⟩⟩ demo

⇧ back to top


Render Props

https://reactjs.org/docs/render-props.html

- name provider

simple component using children as a render prop

import * as React from 'react';

interface NameProviderProps {
  children: (state: NameProviderState) => React.ReactNode;
}

interface NameProviderState {
  readonly name: string;
}

export class NameProvider extends React.Component<NameProviderProps, NameProviderState> {
  readonly state: NameProviderState = { name: 'Piotr' };

  render() {
    return this.props.children(this.state);
  }
}

⟩⟩⟩ demo

⇧ back to top

- mouse provider

Mouse component found in Render Props React Docs

import * as React from 'react';

export interface MouseProviderProps {
  render: (state: MouseProviderState) => React.ReactNode;
}

interface MouseProviderState {
  readonly x: number;
  readonly y: number;
}

export class MouseProvider extends React.Component<MouseProviderProps, MouseProviderState> {
  readonly state: MouseProviderState = { x: 0, y: 0 };

  handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
    this.setState({
      x: event.clientX,
      y: event.clientY,
    });
  };

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

⟩⟩⟩ demo

⇧ back to top


Higher-Order Components

https://reactjs.org/docs/higher-order-components.html

- withState

Adds state to a stateless counter

import * as React from 'react';
import { Subtract } from 'utility-types';

// These props will be subtracted from base component props
interface InjectedProps {
  count: number;
  onIncrement: () => any;
}

export const withState = <BaseProps extends InjectedProps>(
  BaseComponent: React.ComponentType<BaseProps>
) => {
  type HocProps = Subtract<BaseProps, InjectedProps> & {
    // here you can extend hoc with new props
    initialCount?: number;
  };
  type HocState = {
    readonly count: number;
  };

  return class Hoc extends React.Component<HocProps, HocState> {
    // Enhance component name for debugging and React-Dev-Tools
    static displayName = `withState(${BaseComponent.name})`;
    // reference to original wrapped component
    static readonly WrappedComponent = BaseComponent;

    readonly state: HocState = {
      count: Number(this.props.initialCount) || 0,
    };

    handleIncrement = () => {
      this.setState({ count: this.state.count + 1 });
    };

    render() {
      const { ...restProps } = this.props as any;
      const { count } = this.state;

      return (
        <BaseComponent
          count={count} // injected
          onIncrement={this.handleIncrement} // injected
          {...restProps}
        />
      );
    }
  };
};
Click to expand

import * as React from 'react';

import { withState } from '../hoc';
import { FCCounter } from '../components';

const FCCounterWithState = withState(FCCounter);

export default () => <FCCounterWithState label={'FCCounterWithState'} />;

⇧ back to top

- withErrorBoundary

Adds error handling using componentDidCatch to any component

import * as React from 'react';
import { Subtract } from 'utility-types';

const MISSING_ERROR = 'Error was swallowed during propagation.';

// These props will be subtracted from base component props
interface InjectedProps {
  onReset: () => any;
}

export const withErrorBoundary = <BaseProps extends InjectedProps>(
  BaseComponent: React.ComponentType<BaseProps>
) => {
  type HocProps = Subtract<BaseProps, InjectedProps> & {
    // here you can extend hoc with new props
  };
  type HocState = {
    readonly error: Error | null | undefined;
  };

  return class Hoc extends React.Component<HocProps, HocState> {
    // Enhance component name for debugging and React-Dev-Tools
    static displayName = `withErrorBoundary(${BaseComponent.name})`;
    // reference to original wrapped component
    static readonly WrappedComponent = BaseComponent;

    readonly state: HocState = {
      error: undefined,
    };

    componentDidCatch(error: Error | null, info: object) {
      this.setState({ error: error || new Error(MISSING_ERROR) });
      this.logErrorToCloud(error, info);
    }

    logErrorToCloud = (error: Error | null, info: object) => {
      // TODO: send error report to service provider
    };

    handleReset = () => {
      this.setState({ error: undefined });
    };

    render() {
      const { children, ...restProps } = this.props as {
        children: React.ReactNode;
      };
      const { error } = this.state;

      if (error) {
        return (
          <BaseComponent
            onReset={this.handleReset} // injected
            {...restProps}
          />
        );
      }

      return children;
    }
  };
};
Click to expand

import * as React from 'react';

import { withErrorBoundary } from '../hoc';
import { ErrorMessage } from '../components';

const ErrorMessageWithErrorBoundary =
  withErrorBoundary(ErrorMessage);

const BrokenButton = () => (
  <button type="button" onClick={() => { throw new Error(`Catch me!`); }}>
    {`Throw nasty error`}
  </button >
);

export default () => (
  <ErrorMessageWithErrorBoundary>
    <BrokenButton />
  </ErrorMessageWithErrorBoundary>
);

⇧ back to top


Redux Connected Components

Caveat with bindActionCreators

If you try to use connect or bindActionCreators explicitly and want to type your component callback props as () => void this will raise compiler errors. It happens because bindActionCreators typings will not map the return type of action creators to void, due to a current TypeScript limitations.

A decent alternative I can recommend is to use () => any type, it will work just fine in all possible scenarios and should not cause any typing problems whatsoever. All the code examples in the Guide with connect are also using this pattern.

If there is any progress or fix in regard to the above caveat I'll update the guide and make an announcement on my twitter/medium (There are a few existing proposals already).

There is alternative way to retain type soundness but it requires an explicit wrapping with dispatch and will be very tedious for the long run. See example below:

const mapDispatchToProps = (dispatch: Dispatch<ActionType>) => ({
  onIncrement: () => dispatch(actions.increment()),
});

- redux connected counter

import Types from 'MyTypes';
import { connect } from 'react-redux';

import { countersActions, countersSelectors } from '../features/counters';
import { FCCounter } from '../components';

const mapStateToProps = (state: Types.RootState) => ({
  count: countersSelectors.getReduxCounter(state.counters),
});

export const FCCounterConnected = connect(
  mapStateToProps,
  {
    onIncrement: countersActions.increment,
  }
)(FCCounter);
Click to expand

import * as React from 'react';

import { FCCounterConnected } from '.';

export default () => <FCCounterConnected label={'FCCounterConnected'} />;

⇧ back to top

- redux connected counter (verbose)

import Types from 'MyTypes';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';

import { countersActions } from '../features/counters';
import { FCCounter } from '../components';

const mapStateToProps = (state: Types.RootState) => ({
  count: state.counters.reduxCounter,
});

const mapDispatchToProps = (dispatch: Dispatch<Types.RootAction>) =>
  bindActionCreators(
    {
      onIncrement: countersActions.increment,
    },
    dispatch
  );

export const FCCounterConnectedVerbose = connect(
  mapStateToProps,
  mapDispatchToProps
)(FCCounter);
Click to expand

import * as React from 'react';

import { FCCounterConnectedVerbose } from '.';

export default () => (
  <FCCounterConnectedVerbose label={'FCCounterConnectedVerbose'} />
);

⇧ back to top

- with own props

import Types from 'MyTypes';
import { connect } from 'react-redux';

import { countersActions, countersSelectors } from '../features/counters';
import { FCCounter } from '../components';

type OwnProps = {
  initialCount?: number;
};

const mapStateToProps = (state: Types.RootState, ownProps: OwnProps) => ({
  count:
    countersSelectors.getReduxCounter(state.counters) +
    (ownProps.initialCount || 0),
});

export const FCCounterConnectedExtended = connect(
  mapStateToProps,
  {
    onIncrement: countersActions.increment,
  }
)(FCCounter);
Click to expand

import * as React from 'react';

import { FCCounterConnectedExtended } from '.';

export default () => (
  <FCCounterConnectedExtended
    label={'FCCounterConnectedExtended'}
    initialCount={10}
  />
);

⇧ back to top

Context

https://reactjs.org/docs/context.html

ThemeContext

import * as React from 'react';

export type Theme = React.CSSProperties;

type Themes = {
  dark: Theme;
  light: Theme;
};

export const themes: Themes = {
  dark: {
    color: 'black',
    backgroundColor: 'white',
  },
  light: {
    color: 'white',
    backgroundColor: 'black',
  },
};

export type ThemeContextProps = { theme: Theme; toggleTheme?: () => void };
const ThemeContext = React.createContext<ThemeContextProps>({ theme: themes.light });

export default ThemeContext;

⇧ back to top

ThemeProvider

import React from 'react';
import ThemeContext, { themes, Theme } from './theme-context';
import ToggleThemeButton from './theme-consumer';

interface State {
  theme: Theme;
}
export class ThemeProvider extends React.Component<{}, State> {
  readonly state: State = { theme: themes.light };

  toggleTheme = () => {
    this.setState(state => ({
      theme: state.theme === themes.light ? themes.dark : themes.light,
    }));
  }

  render() {
    const { theme } = this.state;
    const { toggleTheme } = this;
    return (
      <ThemeContext.Provider value={{ theme, toggleTheme }}>
        <ToggleThemeButton />
      </ThemeContext.Provider>
    );
  }
}

⇧ back to top

ThemeConsumer

import * as React from 'react';
import ThemeContext from './theme-context';

type Props = {};

export default function ToggleThemeButton(props: Props) {
  return (
    <ThemeContext.Consumer>
      {({ theme, toggleTheme }) => <button style={theme} onClick={toggleTheme} {...props} />}
    </ThemeContext.Consumer>
  );
}

ThemeConsumer in class component

import * as React from 'react';
import ThemeContext from './theme-context';

type Props = {};

export class ToggleThemeButtonClass extends React.Component<Props> {
  static contextType = ThemeContext;
  context!: React.ContextType<typeof ThemeContext>;

  render() {
    const { theme, toggleTheme } = this.context;
    return (
      <button style={theme} onClick={toggleTheme}>
        Toggle Theme
      </button>
    );
  }
}

Implementation with Hooks

⇧ back to top

Hooks

https://reactjs.org/docs/hooks-intro.html

- useState

https://reactjs.org/docs/hooks-reference.html#usestate

import * as React from 'react';

type Props = { initialCount: number };

export default function Counter({initialCount}: Props) {
  const [count, setCount] = React.useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
    </>
  );
}

⇧ back to top

- useReducer

Hook for state management like Redux in a function component.

import * as React from 'react';

interface State {
  count: number;
}

type Action = { type: 'reset' } | { type: 'increment' } | { type: 'decrement' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      throw new Error();
  }
}

interface CounterProps {
  initialCount: number;
}

function Counter({ initialCount }: CounterProps) {
  const [state, dispatch] = React.useReducer(reducer, {
    count: initialCount,
  });

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </>
  );
}

export default Counter;

⇧ back to top

- useContext

https://reactjs.org/docs/hooks-reference.html#usecontext

import * as React from 'react';
import ThemeContext from '../context/theme-context';

type Props = {};

export default function ThemeToggleButton(props: Props) {
  const { theme, toggleTheme } = React.useContext(ThemeContext);
  return (
    <button onClick={toggleTheme} style={theme} >
      Toggle Theme
    </button>
  );
}

⇧ back to top


Redux - Typing Patterns

Store Configuration

Create Global RootState and RootAction Types

RootState - type representing root state-tree

Can be imported in connected components to provide type-safety to Redux connect function

RootAction - type representing union type of all action objects

Can be imported in various layers receiving or sending redux actions like: reducers, sagas or redux-observables epics

declare module 'MyTypes' {
  import { StateType, ActionType } from 'typesafe-actions';
  export type Store = StateType<typeof import('./index').default>;
  export type RootAction = ActionType<typeof import('./root-action').default>;
  export type RootState = StateType<typeof import('./root-reducer').default>;
}

⇧ back to top

Create Store

When creating a store instance we don't need to provide any additional types. It will set-up a type-safe Store instance using type inference.

The resulting store instance methods like getState or dispatch will be type checked and will expose all type errors

import { RootAction, RootState, Services } from 'MyTypes';
import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';

import { composeEnhancers } from './utils';
import rootReducer from './root-reducer';
import rootEpic from './root-epic';
import services from '../services';

export const epicMiddleware = createEpicMiddleware<
  RootAction,
  RootAction,
  RootState,
  Services
>({
  dependencies: services,
});

// configure middlewares
const middlewares = [epicMiddleware];
// compose enhancers
const enhancer = composeEnhancers(applyMiddleware(...middlewares));

// rehydrate state on app start
const initialState = {};

// create store
const store = createStore(rootReducer, initialState, enhancer);

epicMiddleware.run(rootEpic);

// export store singleton instance
export default store;

Action Creators

We'll be using a battle-tested library NPM Downloads that automates and simplify maintenace of type annotations in Redux Architectures typesafe-actions

For more examples and in-depth tutorial you should check The Mighty Tutorial!

A solution below is using a simple factory function to automate the creation of type-safe action creators. The goal is to decrease maintenance effort and reduce code repetition of type annotations for actions and creators. The result is completely typesafe action-creators and their actions.

import { action } from 'typesafe-actions';

import { ADD, INCREMENT } from './constants';

// CLASSIC API
export const increment = () => action(INCREMENT);
export const add = (amount: number) => action(ADD, amount);

// ALTERNATIVE API - allow to use reference to "action-creator" function instead of "type constant"
// e.g. case getType(increment): return { ... }
// This will allow to completely eliminate need for "constants" in your application, more info here:
// https://github.com/piotrwitek/typesafe-actions#behold-the-mighty-tutorial

// OPTION 1 (with generics):
// import { createStandardAction } from 'typesafe-actions';
// export const increment = createStandardAction(INCREMENT)<void>();
// export const add = createStandardAction(ADD)<number>();

// OPTION 2 (with resolve callback):
// import { createAction } from 'typesafe-actions';
// export const increment = createAction(INCREMENT);
// export const add = createAction(ADD, resolve => {
//   return (amount: number) => resolve(amount);
// });
Click to expand

import store from '../../store';
import { countersActions as counter } from '../counters';

// store.dispatch(counter.increment(1)); // Error: Expected 0 arguments, but got 1.
store.dispatch(counter.increment()); // OK

// store.dispatch(counter.add()); // Error: Expected 1 arguments, but got 0.
store.dispatch(counter.add(1)); // OK

⇧ back to top


Reducers

State with Type-level Immutability

Declare reducer State type with readonly modifier to get compile time immutability

export type State = {
  readonly counter: number;
  readonly todos: ReadonlyArray<string>;
};

Readonly modifier allow initialization, but will not allow reassignment by highlighting compiler errors

export const initialState: State = {
  counter: 0,
}; // OK

initialState.counter = 3; // TS Error: cannot be mutated

It's great for Arrays in JS because it will error when using mutator methods like (push, pop, splice, ...), but it'll still allow immutable methods like (concat, map, slice,...).

state.todos.push('Learn about tagged union types') // TS Error: Property 'push' does not exist on type 'ReadonlyArray<string>'
const newTodos = state.todos.concat('Learn about tagged union types') // OK

Caveat: Readonly is not recursive

This means that the readonly modifier doesn't propagate immutability down the nested structure of objects. You'll need to mark each property on each level explicitly.

To fix this we can use DeepReadonly type (available in utility-types npm library - collection of reusable types extending the collection of standard-lib in TypeScript.

Check the example below:

import { DeepReadonly } from 'utility-types';

export type State = DeepReadonly<{
  containerObject: {
    innerValue: number,
    numbers: number[],
  }
}>;

state.containerObject = { innerValue: 1 }; // TS Error: cannot be mutated
state.containerObject.innerValue = 1; // TS Error: cannot be mutated
state.containerObject.numbers.push(1); // TS Error: cannot use mutator methods

Best-practices for nested immutability

use Readonly or ReadonlyArray Mapped types

export type State = Readonly<{
  counterPairs: ReadonlyArray<Readonly<{
    immutableCounter1: number,
    immutableCounter2: number,
  }>>,
}>;

state.counterPairs[0] = { immutableCounter1: 1, immutableCounter2: 1 }; // TS Error: cannot be mutated
state.counterPairs[0].immutableCounter1 = 1; // TS Error: cannot be mutated
state.counterPairs[0].immutableCounter2 = 1; // TS Error: cannot be mutated

⇧ back to top

Typing reducer

to understand following section make sure to learn about Type Inference, Control flow analysis and Tagged union types

import { combineReducers } from 'redux';
import { ActionType } from 'typesafe-actions';

import { Todo, TodosFilter } from './models';
import * as actions from './actions';
import { ADD, CHANGE_FILTER, TOGGLE } from './constants';

export type TodosState = {
  readonly todos: Todo[];
  readonly todosFilter: TodosFilter;
};

export type TodosAction = ActionType<typeof actions>;

export default combineReducers<TodosState, TodosAction>({
  todos: (state = [], action) => {
    switch (action.type) {
      case ADD:
        return [...state, action.payload];

      case TOGGLE:
        return state.map(item =>
          item.id === action.payload
            ? { ...item, completed: !item.completed }
            : item
        );

      default:
        return state;
    }
  },
  todosFilter: (state = TodosFilter.All, action) => {
    switch (action.type) {
      case CHANGE_FILTER:
        return action.payload;

      default:
        return state;
    }
  },
});

⇧ back to top

Testing reducer

import {
  todosReducer as reducer,
  todosActions as actions,
  TodosState,
} from './';

/**
 * FIXTURES
 */
const getInitialState = (initial?: Partial<TodosState>) =>
  reducer(initial as TodosState, {} as any);

/**
 * STORIES
 */
describe('Todos Stories', () => {
  describe('initial state', () => {
    it('should match a snapshot', () => {
      const initialState = getInitialState();
      expect(initialState).toMatchSnapshot();
    });
  });

  describe('adding todos', () => {
    it('should add a new todo as the first element', () => {
      const initialState = getInitialState();
      expect(initialState.todos).toHaveLength(0);
      const state = reducer(initialState, actions.add('new todo'));
      expect(state.todos).toHaveLength(1);
      expect(state.todos[0].title).toEqual('new todo');
    });
  });

  describe('toggling completion state', () => {
    it('should mark active todo as complete', () => {
      const activeTodo = { id: '1', completed: false, title: 'active todo' };
      const initialState = getInitialState({ todos: [activeTodo] });
      expect(initialState.todos[0].completed).toBeFalsy();
      const state1 = reducer(initialState, actions.toggle(activeTodo.id));
      expect(state1.todos[0].completed).toBeTruthy();
    });
  });
});

⇧ back to top


Async Flow with redux-observable

For more examples and in-depth tutorial you should check The Mighty Tutorial!

Typing epics

import { RootAction, RootState, Services } from 'MyTypes';
import { Epic } from 'redux-observable';
import { tap, ignoreElements, filter } from 'rxjs/operators';
import { isOfType } from 'typesafe-actions';

import { todosConstants } from '../todos';

// contrived example!!!
export const logAddAction: Epic<RootAction, RootAction, RootState, Services> = (
  action$,
  state$,
  { logger }
) =>
  action$.pipe(
    filter(isOfType(todosConstants.ADD)), // action is narrowed to: { type: "ADD_TODO"; payload: string; }
    tap(action => {
      logger.log(
        `action type must be equal: ${todosConstants.ADD} === ${action.type}`
      );
    }),
    ignoreElements()
  );

⇧ back to top

Testing epics

import { StateObservable, ActionsObservable } from 'redux-observable';
import { RootState, Services, RootAction } from 'MyTypes';
import { Subject } from 'rxjs';

import { add } from './actions';
import { logAddAction } from './epics';

// Simple typesafe mock of all the services, you dont't need to mock anything else
// It is decoupled and reusable for all your tests, just put it in a separate file
const services = {
  logger: {
    log: jest.fn<Services['logger']['log']>(),
  },
  localStorage: {
    loadState: jest.fn<Services['localStorage']['loadState']>(),
    saveState: jest.fn<Services['localStorage']['saveState']>(),
  },
};

describe('Todos Epics', () => {
  let state$: StateObservable<RootState>;

  beforeEach(() => {
    state$ = new StateObservable<RootState>(
      new Subject<RootState>(),
      undefined as any
    );
  });

  describe('logging todos actions', () => {
    beforeEach(() => {
      services.logger.log.mockClear();
    });

    it('should call the logger service when adding a new todo', done => {
      const addTodoAction = add('new todo');
      const action$ = ActionsObservable.of(addTodoAction);

      logAddAction(action$, state$, services)
        .toPromise()
        .then((outputAction: RootAction) => {
          expect(services.logger.log).toHaveBeenCalledTimes(1);
          expect(services.logger.log).toHaveBeenCalledWith(
            'action type must be equal: todos/ADD === todos/ADD'
          );
          // expect output undefined because we're using "ignoreElements" in epic
          expect(outputAction).toEqual(undefined);
          done();
        });
    });
  });
});

⇧ back to top


Selectors

"reselect"

import { createSelector } from 'reselect';

import { TodosState } from './reducer';

export const getTodos = (state: TodosState) => state.todos;

export const getTodosFilter = (state: TodosState) => state.todosFilter;

export const getFilteredTodos = createSelector(getTodos, getTodosFilter, (todos, todosFilter) => {
  switch (todosFilter) {
    case 'completed':
      return todos.filter(t => t.completed);
    case 'active':
      return todos.filter(t => !t.completed);

    default:
      return todos;
  }
});

⇧ back to top


Typing connect

Below snippet can be find in the playground/ folder, you can checkout the repo and follow all dependencies to understand the bigger picture. playground/src/connected/fc-counter-connected-verbose.tsx

import Types from 'Types';

import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';

import { countersActions } from '../features/counters';
import { FCCounter } from '../components';

// `state` parameter needs a type annotation to type-check the correct shape of a state object but also it'll be used by "type inference" to infer the type of returned props
const mapStateToProps = (state: Types.RootState, ownProps: FCCounterProps) => ({
  count: state.counters.reduxCounter,
});

// `dispatch` parameter needs a type annotation to type-check the correct shape of an action object when using dispatch function
const mapDispatchToProps = (dispatch: Dispatch<Types.RootAction>) => bindActionCreators({
  onIncrement: countersActions.increment,
  // without using action creators, this will be validated using your RootAction union type
  // onIncrement: () => dispatch({ type: "counters/INCREMENT" }),
}, dispatch);

// NOTE: We don't need to pass generic type arguments to neither connect nor mapping functions because type inference will do all this work automatically. So there's really no reason to increase the noise ratio in your codebase!
export const FCCounterConnectedVerbose =
  connect(mapStateToProps, mapDispatchToProps)(FCCounter);

⇧ back to top


Tools

TSLint

Installation
npm i -D tslint

tslint.json

  • Recommended setup is to extend build-in preset tslint:recommended (use tslint:all to enable all rules)
  • Add additional react specific rules: npm i -D tslint-react https://github.com/palantir/tslint-react
  • Overwritten some defaults for more flexibility
Click to expand

{
  "extends": ["tslint:recommended", "tslint-react"],
  "rules": {
    "arrow-parens": false,
    "arrow-return-shorthand": [false],
    "comment-format": [true, "check-space"],
    "import-blacklist": [true],
    "interface-over-type-literal": false,
    "interface-name": false,
    "max-line-length": [true, 120],
    "member-access": false,
    "member-ordering": [true, { "order": "fields-first" }],
    "newline-before-return": false,
    "no-any": false,
    "no-empty-interface": false,
    "no-import-side-effect": [true],
    "no-inferrable-types": [true, "ignore-params", "ignore-properties"],
    "no-invalid-this": [true, "check-function-in-method"],
    "no-namespace": false,
    "no-null-keyword": false,
    "no-require-imports": false,
    "no-submodule-imports": [true, "@src", "rxjs"],
    "no-this-assignment": [true, { "allow-destructuring": true }],
    "no-trailing-whitespace": true,
    "object-literal-sort-keys": false,
    "object-literal-shorthand": false,
    "one-variable-per-declaration": [false],
    "only-arrow-functions": [true, "allow-declarations"],
    "ordered-imports": [false],
    "prefer-method-signature": false,
    "prefer-template": [true, "allow-single-concat"],
    "quotemark": [true, "single", "jsx-double"],
    "semicolon": [true, "always", "ignore-bound-class-methods"],
    "trailing-comma": [
      true,
      {
        "singleline": "never",
        "multiline": {
          "objects": "always",
          "arrays": "always",
          "functions": "ignore",
          "typeLiterals": "ignore"
        },
        "esSpecCompliant": true
      }
    ],
    "triple-equals": [true, "allow-null-check"],
    "type-literal-delimiter": true,
    "typedef": [true, "parameter", "property-declaration"],
    "variable-name": [
      true,
      "ban-keywords",
      "check-format",
      "allow-pascal-case",
      "allow-leading-underscore"
    ],
    // tslint-react
    "jsx-no-multiline-js": false,
    "jsx-no-lambda": false
  }
}

⇧ back to top

Jest

Installation
npm i -D jest ts-jest @types/jest

jest.config.json

Click to expand

{
  "verbose": true,
  "transform": {
    ".(ts|tsx)": "ts-jest"
  },
  "testRegex": "(/spec/.*|\\.(test|spec))\\.(ts|tsx|js)$",
  "moduleFileExtensions": ["ts", "tsx", "js"],
  "moduleNameMapper": {
    "^Components/(.*)": "./src/components/$1"
  },
  "globals": {
    "window": {},
    "ts-jest": {
      "tsConfig": "./tsconfig.json"
    }
  },
  "setupFiles": ["./jest.stubs.js"],
  "testURL": "http://localhost/"
}

jest.stubs.js

Click to expand

// Global/Window object Stubs for Jest
window.matchMedia = window.matchMedia || function () {
  return {
    matches: false,
    addListener: function () { },
    removeListener: function () { },
  };
};

window.requestAnimationFrame = function (callback) {
  setTimeout(callback);
};

window.localStorage = {
  getItem: function () { },
  setItem: function () { },
};

Object.values = () => [];

⇧ back to top

Living Style Guide

⟩⟩⟩ styleguide.config.js

⟩⟩⟩ demo

⇧ back to top

Common Npm Scripts

Common TS-related npm scripts shared across projects

"lint": "tslint -p ./",
"tsc": "tsc -p ./ --noEmit",
"tsc:watch": "tsc -p ./ --noEmit -w",
"pretest": "npm run lint & npm run tsc",
"test": "jest --config jest.config.json",
"test:watch": "jest --config jest.config.json --watch",
"test:update": "jest --config jest.config.json -u",

⇧ back to top


Recipes

Baseline tsconfig.json

  • Recommended baseline config carefully optimized for strict type-checking and optimal webpack workflow
  • Install tslib to cut on bundle size, by using external runtime helpers instead of adding them inline: npm i tslib
  • Example "paths" setup for baseUrl relative imports with Webpack
Click to expand

{
  "compilerOptions": {
    "baseUrl": "./", // relative paths base
    "paths": {
      // "@src/*": ["src/*"] // will enable import aliases -> import { ... } from '@src/components'
      // WARNING: Require to add this to your webpack config -> resolve: { alias: { '@src': PATH_TO_SRC } }
      // "redux": ["typings/redux"], // override library types with your alternative type-definitions in typings folder
    },
    "outDir": "dist/", // target for compiled files
    "allowSyntheticDefaultImports": true, // no errors with commonjs modules interop
    "esModuleInterop": true, // enable to do "import React ..." instead of "import * as React ..."
    "allowJs": true, // include js files
    "checkJs": true, // typecheck js files
    "declaration": false, // don't emit declarations
    "emitDecoratorMetadata": true, // include only if using decorators
    "experimentalDecorators": true, // include only if using decorators
    "forceConsistentCasingInFileNames": true,
    "importHelpers": true, // importing transpilation helpers from tslib
    "noEmitHelpers": true, // disable inline transpilation helpers in each file
    "jsx": "react", // transform JSX
    "lib": ["dom", "es2017"], // you will need to include polyfills for es2017 manually
    "types": ["jest"], // which global types to use
    "target": "es5", // "es2015" for ES6+ engines
    "module": "es2015", // "es2015" for tree-shaking
    "moduleResolution": "node",
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "strict": true,
    "pretty": true,
    "removeComments": true,
    "sourceMap": true
  },
  "include": ["src", "typings"]
}

⇧ back to top

General Tips

- should I still use React.PropTypes in TS?

No. With TypeScript, using PropTypes is an unnecessary overhead. When declaring Props and State interfaces, you will get complete intellisense and design-time safety with static type checking. This way you'll be safe from runtime errors and you will save a lot of time on debugging. Additional benefit is an elegant and standardized method of documenting your component public API in the source code.

⇧ back to top

- when to use interface declarations and when type aliases?

From practical side, using interface declaration will create an identity (interface name) in compiler errors, on the contrary type aliases doesn't create an identity and will be unwinded to show all the properties and nested types it consists of.
Although I prefer to use type most of the time there are some places this can become too noisy when reading compiler errors and that's why I like to leverage this distinction to hide some of not so important type details in errors using interfaces identity. Related ts-lint rule: https://palantir.github.io/tslint/rules/interface-over-type-literal/

⇧ back to top

- what's better default or named exports?

A common flexible solution is to use module folder pattern, because you can leverage both named and default import when you see fit.
With this solution you'll achieve better encapsulation and be able to safely refactor internal naming and folders structure without breaking your consumer code:

// 1. create your component files (`select.tsx`) using default export in some folder:

// components/select.tsx
const Select: React.FC<Props> = (props) => {
...
export default Select;

// 2. in this folder create an `index.ts` file that will re-export components with named exports:

// components/index.ts
export { default as Select } from './select';
...

// 3. now you can import your components in both ways, with named export (better encapsulation) or using default export (internal access):

// containers/container.tsx
import { Select } from '@src/components';
or
import Select from '@src/components/select';
...

⇧ back to top

- how to best initialize class instance or static properties?

Prefered modern syntax is to use class Property Initializers

class ClassCounterWithInitialCount extends React.Component<Props, State> {
  // default props using Property Initializers
  static defaultProps: DefaultProps = {
    className: 'default-class',
    initialCount: 0,
  };
  
  // initial state using Property Initializers
  state: State = {
    count: this.props.initialCount,
  };
  ...
}

⇧ back to top

- how to best declare component handler functions?

Prefered modern syntax is to use Class Fields with arrow functions

class ClassCounter extends React.Component<Props, State> {
// handlers using Class Fields with arrow functions
  handleIncrement = () => {
    this.setState({ count: this.state.count + 1 });
  };
  ...
}

⇧ back to top

Ambient Modules Tips

Imports in ambient modules

For type augmentation imports should stay outside of module declaration.

import { Operator } from 'rxjs/Operator';
import { Observable } from 'rxjs/Observable';

declare module 'rxjs/Subject' {
  interface Subject<T> {
    lift<R>(operator: Operator<T, R>): Observable<R>;
  }
}

When creating 3rd party type-definitions all the imports should be kept inside the module decleration, otherwise it will be treated as augmentation and show error

declare module "react-custom-scrollbars" {
    import * as React from "react";
    export interface positionValues {
    ...

⇧ back to top

Type-Definitions Tips

Missing type-definitions error

if you cannot find types for a third-party module you can provide your own types or disable type-checking for this module using Shorthand Ambient Modules

// typings/modules.d.ts
declare module 'MyTypes';
declare module 'react-test-renderer';

Using custom d.ts files for npm modules

If you want to use an alternative (customized) type-definitions for some npm module (that usually comes with it's own type-definitions), you can do it by adding an override in paths compiler option.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "redux": ["typings/redux"], // use an alternative type-definitions instead of the included one
      ...
    },
    ...,
  }
}

⇧ back to top

Type Augmentation Tips

Strategies to fix issues coming from external type-definitions files (*.d.ts)

Augmenting library internal declarations - using relative import

// added missing autoFocus Prop on Input component in "[email protected]" npm package
declare module '../node_modules/antd/lib/input/Input' {
  export interface InputProps {
    autoFocus?: boolean;
  }
}

Augmenting library public declarations - using node_modules import

// fixed broken public type-definitions in "[email protected]" npm package
import { Operator } from 'rxjs/Operator';
import { Observable } from 'rxjs/Observable';

declare module 'rxjs/Subject' {
  interface Subject<T> {
    lift<R>(operator: Operator<T, R>): Observable<R>;
  }
}

More advanced scenarios for working with vendor type-definitions can be found here Official TypeScript Docs

⇧ back to top


Tutorials & Articles

Curated list of relevant in-depth tutorials

Higher-Order Components:

⇧ back to top


Contributors

Thanks goes to these wonderful people (emoji key):


Piotrek Witek

💻 📖 🤔 👀 💬

Kazz Yokomizo

💵 🔍

Jake Boone

📖

Amit Dahan

📖

gulderov

📖

Erik Pearson

📖

Bryan Mason

📖

Jakub Chodorowicz

💻

Oleg Maslov

🐛

Aaron Westbrook

🐛

Peter Blazejewicz

📖

Solomon White

📖

Levi Rocha

📖

Sudachi-kun

💵

Sosuke Suzuki

💻

Tom Rathbone

📖

Arshad Kazmi

📖

JeongUkJae

📖

This project follows the all-contributors specification. Contributions of any kind welcome!


MIT License

Copyright (c) 2017 Piotr Witek [email protected] (http://piotrwitek.github.io)