Building Scalable, Maintainable Apps Using TypeScript and React

Kamran Ayub

midwest.js 2017

bit.ly/mjs-react-ts

patterns and practices

Watch 2018 NDC Talk:

bit.ly/ndcmn-react-ts-video

console.log("Hello")

  • Pluralsight (soon) & Packt author
  • 15+ years of hell JavaScript
  • Minneapolis dev
     
  • About 6 months of React experience

What do I want?

To make my apps easier to maintain

To make my apps faster

To make my apps easier to scale

Scalable

Scalable

Easy to extend

 

Easy to collaborate on

 

Easy to reuse code

Maintainable

Easy to refactor

 

Easy to understand

 

Easy to test

TypeScript

JavaScript that scales

  • Static type checking
  • Fantastic editor tooling
  • Refactor-friendly code

React

Component-based presentation

  • Small surface area
  • Reusable components
  • Functional characteristics
  • Great performance

ktomg.com

  • Built in .NET
  • Hybrid app
  • Originally JavaScript & Knockout.js
  • Migrating to TypeScript & React
  • Heavily interactive UI

Based on a true story

(work in progress)

Tools & Libraries

Build

 

webpack 3

typescript 2.4

ts-loader

React Stack

 

react (15.4)

redux

react-redux

redux-thunk

reselect

react-bootstrap

react-transition-group

Other

 

lodash

classnames

moment

urijs

Concepts

  • Store - Single source of truth for app
  • State - The data representing the app at a point-in-time
  • Actions - Redux actions that can be dispatched
  • Reducers - Redux reducer functions to mutate state
  • Selectors - Memoized getters for computed data
  • Services - Business logic

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Configuring webpack

entry: {
    common: ["react", "react-dom", "reselect", "redux", 
             "react-redux", "lodash", "classnames", "react-bootstrap"],
    base: "./scripts/core/base.ts",
    index: "./scripts/apps/Index.tsx",
    list_organize: "./scripts/apps/OrganizeList.tsx"
},
output: {
    filename: "[name].bundle.js",
    path: path.join(__dirname, "scripts/bundles")
},    
plugins: [
    new webpack.optimize.CommonsChunkPlugin({
        name: "common",
        minChunks: Infinity
    })
]

using with ts-loader (no special config)

Tool Configuration

Configuring TypeScript

{
    "compileOnSave": false,
    "compilerOptions": {
        "sourceMap": true,
        "jsx": "react",
        "target": "es5",
        "module": "commonjs",
        "moduleResolution": "node",
        "lib": [ "dom", "es5", "es2015.symbol", "es2015.core" ]
    }
}

tsconfig.json

Tool Configuration

Folder layout

(previous)

├───apps (webpack entry points)
├───bundles (webpack dists)
├───core
│   ├───actions
│   │       GamesActions.ts
│   │
│   ├───components
│   │   │   LeftNavigation.tsx
│   │   │
│   │   ├───common
│   │   └───games
│   │           AddGameDialog.tsx
│   │
│   ├───reducers
│   │       GamesReducer.ts
│   │       Index.ts
│   │       State.ts
│   │
│   ├───selectors
│   │       GamesSelectors.ts
│   │
│   ├───services
│   │       GamesService.ts
│   │
│   └───store
│           Index.ts
│
└───lib (vendor)
  • Files are spread out
  • Import paths get long
  • Fine for a small site

Tool Configuration

Folder Layout

Folder layout

(new)

├───apps
├───bundles
├───core
│   │
│   ├───features
│   │   ├───game-details
│   │   ├───games
│   │   │   │   GamesActions.ts
│   │   │   │   GamesReducers.ts
│   │   │   │   GamesSelectors.ts
│   │   │   │   GamesService.ts
│   │   │   │
│   │   │   └───components
│   │   │           AddGameDialog.tsx
│   │   │
│   │   ├───list-organize
│   │   ├───shared
│   │   │       LeftNavigation.tsx
│   │   │
│   │   └───users
│   └───store
│           Index.ts
│           RootReducer.ts
│           State.ts
│
└───lib
  • Feature organization
  • Better collaboration
  • Colocate related files
  • Closer imports

Tool Configuration

Folder Layout

webpack & TS aliases

// store/RootReducer.ts

import GamesReducer from '../features/games/GamesReducer'
import UsersReducer from '../features/users/UsersReducer'
// store/RootReducer.ts

import GamesReducer from 'Features/games/GamesReducer'
import UsersReducer from 'Features/users/UsersReducer'
// webpack.config.js

resolve: {
  alias: {
    Features: path.join(__dirname, "scripts/core/features")
  },
  // ...
}
// tsconfig.json

compilerOptions: {
  baseUrl: ".",
  paths: {
    "Features/*": ["scripts/core/features/*"]
  },
  // ...
}

Not bad but nested paths will get worse

Instead we can have an alias for specific paths

You can set the "alias" configuration in Webpack

And then also set up "paths" in tsconfig.json file

 

(ts-awesome-loader supports this without configuring webpack)

Tool Configuration

Folder Layout

Redux Store

Representing state shape

// store/State.ts
import { initialState as UserStoreState } from 'Features/users/UsersReducer'
import { initialState as GamesStoreState } from 'Features/games/GamesReducer'
import { initialState as ListsStoreState } from 'Features/lists/ListsReducer'
import { initialState as ListOrganizeStoreState } from 'Features/list-organize/ListOrganizeReducer'
import { initialState as LoadingStoreState } from 'Features/shared/loading/LoadingReducer'

export interface AppState {
  user: typeof UserStoreState
  games: typeof GamesStoreState
  lists: typeof ListsStoreState
  ui: {
    loading: typeof LoadingStoreState
    listOrganize: typeof ListOrganizeStoreState
  }
}

export interface NumericStoreState<T> {
    allIds: number[];
    byId: { [key: number]: T};
}

Tool Configuration

Folder Layout

Store & Reducers

Redux Store

Initial state

// Features/games/GamesReducer.ts

interface GamesStoreState extends NumericStoreState<Game> {
  byListId: {[key: number]: Game}
}

export const initialState: GamesStoreState = {
  allIds: [],
  byId: {},
  byListId: {}
}

Tool Configuration

Folder Layout

Store & Reducers

Redux Store

State example

{
  user: { username: "kamranicus", ... },
  games: {
    allIds: [235, 3532],
    byId: {
      235: { title: "Skyrim" },
      3532: { title: "Fallout 4" }
    }
  },
  lists: ...,
  ui: {
    loading: { isBusy: false },
    listOrganize: { name: "My Games", sortBy: "Name", ... }
  }
}

Tool Configuration

Folder Layout

Store & Reducers

Reducers

Root reducer combining slices

// Store/Index.ts
import AppState from './State'

import user from 'Features/user/UserReducer.ts'
import games from 'Features/games/GamesReducer.ts'
import lists from 'Features/lists/ListsReducer.ts'
import listOrganize from 'Features/list-organize/ListOrganizeReducer.ts'
import loading from 'Features/common/loading/LoadingReducer.ts'

const rootReducer = combineReducers<AppState>({
  user,
  games,
  lists,
  ui: combineReducers({
    loading,
    listOrganize
  })
})

Tool Configuration

Folder Layout

Store & Reducers

Reducers

Slicing reducers

// Features/games/GamesReducer.ts

interface GamesStoreState extends NumericStoreState<Game> {
  byListId: {[key: number]: Game}
}

export const initialState: GamesStoreState = {
  allIds: [],
  byId: {},
  byListId: {}
}

function allIds(state = initialState.allIds, action) {
}

function byId(state = initialState.byId, action) {
}

function byListId(state = initialState.byListId, action) {
}

export default combineReducers({
  allIds,
  byId,
  byListId
})

Tool Configuration

Folder Layout

Store & Reducers

Actions

Using constants and type aliases

// GamesActions.ts
export const SAVE_GAME_NOTE = "SAVE_GAME_NOTE"

export type SaveGameNoteAction = { 
  type: typeof SAVE_GAME_NOTE, 
  gameId: number,
  body: string 
}

A bit wordy, but pays off at scale

Tool Configuration

Folder Layout

Store & Reducers

Actions

Actions

Grouping similar actions

// GamesActions.ts
export const SAVE_GAME_NOTE = "SAVE_GAME_NOTE"
export const DELETE_GAME_NOTE = "DELETE_GAME_NOTE"

export type SaveGameNoteAction = { 
  type: typeof SAVE_GAME_NOTE,
  gameId: number,
  body: string 
}

export type DeleteGameNoteAction = {
  type: typeof DELETE_GAME_NOTE,
  gameId: number
}

export type All = 
    SaveGameNoteAction
  | DeleteGameNoteAction

Tool Configuration

Folder Layout

Store & Reducers

Actions

Actions

Importing into reducers

// Features/games/GamesReducer.ts

import * as Actions from './GamesActions'

function byId(state = initialState.byId, action: Actions.All) {
  switch (action.type) {

    case Actions.SAVE_GAME_NOTE:
      return handleSaveGameNote(state, action)

    case Actions.DELETE_GAME_NOTE:
      return handleDeleteGameNote(state, action)

    default: 
      return state
  }
}

function handleSaveGameNote(state: typeof initialState.byId, action: Actions.SaveGameNoteAction) {
}

function handleDeleteGameNote(state: typeof initialState.byId, action: Actions.DeleteGameNoteAction) {
}

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

(stateful, non-redux)

// EditGameNote.tsx
import * as React from 'react'

interface Props {
  gameId: number
  note: Note
  onEdit: () => void
}

interface State {
  editing: boolean
  body: string
}

export default EditGameNote extends React.PureComponent<Props, State> {

}

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

(stateless, non-redux)

// EditGameNote.tsx
import * as React from 'react'

interface Props {
  gameId: number
  note: Note
  onEdit: () => void
}

export default const EditGameNote: React.StatelessComponent<Props> = (props) => (
  <div />
)

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

(stateful, redux)

// EditGameNote.tsx
import * as React from 'react'
import { connect, DispatchProp } from 'react-redux'

import { AppState } from 'Store/State'

interface Props {
  gameId: number
}

interface StateProps {
  canDelete: boolean
}

interface State {
  editing: boolean
  body: string
}

class EditGameNote extends 
  React.PureComponent<Props & StateProps & DispatchProp<AppState>, State> {

}

function mapStateToProps(state: AppState, ownProps: Props): StateProps {
  return { canDelete: state.user.isAdmin }
}

export default connect(mapStateToProps)(EditGameNote)

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

(stateless, redux)

// EditGameNote.tsx
import * as React from 'react'
import { connect, DispatchProp } from 'react-redux'

import { AppState } from 'Store/State'

interface Props {
  gameId: number
  note: Note
  onEdit: () => void
}

interface StateProps {
  canDelete: boolean
}

const EditGameNote: React.StatelessComponent<Props & StateProps & DispatchProp<AppState>> = 
  (props) => (
  <div />
)

function mapStateToProps(state: AppState): StateProps {
  return { canDelete: state.user.isAdmin }
}

export default connect(mapStateToProps)(EditGameNote)

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

Simplifying the generic types

class EditGameNote extends 
  React.PureComponent<Props & StateProps & DispatchProp<AppState>, State> {

}

const EditGameNote: React.StatelessComponent<Props & StateProps & DispatchProp<AppState>> = 
  (props) => (
  <div />
)

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

// common/Redux.ts
import { PureComponent, StatelessComponent } from 'react'
import { DispatchProp } from 'react-redux'
import { AppState } from 'Store/State'

export class ReduxComponent<TProps = {}, TStateProps = {}, TState = {}>
    extends PureComponent<TProps & TStateProps & DispatchProp<AppState>, TState>
{
}

export interface ReduxStatelessComponent<TProps = {}, TStateProps = {}>
    extends StatelessComponent<TProps & TStateProps & DispatchProp<AppState>>
{    
}

Now we can use this class or interface

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Props & State

import { ReduxComponent, ReduxStatelessComponent } from 'Features/common/Redux'

class EditGameNote extends ReduxComponent<Props, StateProps, State> {

}

const EditGameNote: ReduxStatelessComponent<Props, StateProps> = (props) => (
  <div />
)

Easier to read and avoids duplicated constructs for DispatchProp

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Creating a sync action

// GamesActions.ts
export const SAVE_GAME_NOTE = "SAVE_GAME_NOTE"

export type SaveGameNoteAction = { 
  type: typeof SAVE_GAME_NOTE,
  gameId: number,
  body: string 
}

export const saveGameNote = (gameId: number, body: string): SaveGameNoteAction => (
  { type: SAVE_GAME_NOTE, gameId, body })

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

export const saveGameNote = (gameId: number, body: string) => (
  <SaveGameNoteAction>{ type: SAVE_GAME_NOTE, gameId, body })

Another equivalent way:

Action Creators

Dispatching sync actions

// EditGameNote.tsx
import { saveGameNote } from '../GamesActions'

class EditGameNote extends ReduxComponent<Props, StateProps, State> {

  saveNote = (e) => {
    this.props.dispatch(saveGameNote(this.state.body))
  }

  render() {
    return (
      <button onClick={this.saveNote} />
    )
  }
}

Easier than bindActionCreators or mapping dispatch props, honestly

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Action Creators

Async actions (w/ redux-thunk)

// GamesActions.ts
export const SAVE_GAME_NOTE = "SAVE_GAME_NOTE"
export const SAVE_GAME_NOTE_SUCCESS = "SAVE_GAME_NOTE_SUCCESS"

export type SaveGameNoteAction = { 
  type: typeof SAVE_GAME_NOTE_SUCCESS,
  gameId: number
  body: string 
}

export const saveGameNoteSuccess = (gameId: number, body: string): SaveGameNoteAction => (
  { type: SAVE_GAME_NOTE_SUCCESS, gameId, body })

export const saveGameNoteAsync = (gameId: number, body: string) => {

  return function (dispatch: Dispatch<AppState>, getStore: () => AppState) {

    // fetch async
    return GameNotesService.put(gameId, body).then(result => {

      if (result.success) {
        dispatch(saveGameNoteSuccess(gameId, body))
      }

      return result.value
    })
  }
}

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Action Creators

Dispatching async actions

// EditGameNote.tsx
import { saveGameNoteAsync } from '../GamesActions'

class EditGameNote extends ReduxComponent<Props, StateProps, State> {

  saveNote = (e) => {
    let { gameId } = this.props
    let { body } = this.state

    this.props.dispatch(saveGameNoteAsync(gameId, body)).then(note => {
      // do something
    })
  }

  render() {
    return (
      <button onClick={this.saveNote} />
    )
  }
}

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Action Creators

All type-safe with intellisense

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Memoization

function getSomethingHeavy(id) {
  // O(n^2) or something...
  return result
}

function getSomethingHeavySmarter(id) {
  // see if we already found result for given id
  // if so, return 
  // else execute and cache result
}

// lodash can memoize for us
const getSomethingHeavyMemo = _.memoize(getSomethingHeavy)

It's caching for functions!

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Selectors

mapStateToProps

By default, uses shallow equals so computed arrays cause new props leading to unnecessary re-renders

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Selectors

Using reselect

// Features/games/GamesSelectors.ts

import * as _ from 'lodash'
import { createSelector } from 'reselect'

export const getGameMap = (state: AppState) =>
  state.games.byId

export const getGame = (state: AppState, id: number) =>
  state.games.byId[id]

export const getGames = createSelector(getGameMap, 
  (games) => _.values(games))

Derived data includes Lodash manipulation

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Selectors

Referencing in mapStateToProps

// Features/lists/components/ListHeader.tsx
import * as _ from 'lodash'
import { createSelector } from 'reselect'
import { getGames } from './GamesSelectors'

const getTopImages = createSelector(getGames, (games) =>
  _.chain(games).take(4).map(g => g.thumbnailUrl).value())

function mapStateToProps(state: AppState) {
  let topGameImages = getTopImages(state)
  
  return { topGameImages }
}

Tool Configuration

Folder Layout

Store & Reducers

Actions

Props & State

Action Creators

Selectors

Performance

Going from 800ms to 4ms

What am I measuring?

  • Initial render time
  • Re-render time for single item
  • Action dispatch time to render

 

Measure in development mode (intensifies issues)

How am I measuring?

  • console.time/timeEnd
  • Chrome timeline with ?react_perf

Before

  • Initial render time: 800ms
  • Re-render time for single item: 800ms
  • Action dispatch time to render: 1500ms

Optimization #1

Memoized selectors in mapStateToProps

800ms ➡️ 30ms

re-render time

Optimization #2

Pass IDs to children, not objects

30ms ➡️ 20ms

re-render time

Optimization #3

Remove redux-immutable-state-invariant-middleware

1500ms ➡️ 40ms

dispatch time

Optimization #4

Simplify render() logic

20ms ➡️ 5ms

re-render time

40ms ➡️ 10ms

dispatch time

Optimization #5

Virtualize list (scroll to append)

800ms ➡️ 200ms

initial render time

After

  • Initial render time: ~190ms
  • Re-render time for single item: ~5ms
  • Action dispatch time to render: 10ms

Production measurements

  • Initial render time: ~70ms
  • Re-render time for single item: ~2ms
  • Action dispatch time to render: ~5ms

It's all about UX

There's always room for improvement

Thanks for listening!

@kamranayub

kamranicus.com

 

Introduction to TypeScript (Packt)

bit.ly/introts

React UI and Unit Testing with Airbnb's Enzyme (Thu 2:30pm)

Resilient React.js - building apps with a functional approach (Thu 4pm)

Flow - Am I Your Type? (Thu 4pm)

Your code is lying to you (Fri 10:30am)

Static Typing and Bundling for Vue.js with Typescript and Webpack 2 (Fri 11:30am)

Hot React Testing Practices (Fri 2:30pm)