Building Scalable, Maintainable Apps Using TypeScript and React
Kamran Ayub
midwest.js 2017
patterns and practices
Watch 2018 NDC Talk:
console.log("Hello")
- Pluralsight (soon) & Packt author
- 15+ years of
hellJavaScript - 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!
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)
React TS
By Kamran Ayub
React TS
- 2,399