Kamran Ayub
midwest.js 2017
To make my apps easier to maintain
To make my apps faster
To make my apps easier to scale
Easy to extend
Easy to collaborate on
Easy to reuse code
Easy to refactor
Easy to understand
Easy to test
webpack 3
typescript 2.4
ts-loader
react (15.4)
redux
react-redux
redux-thunk
reselect
react-bootstrap
react-transition-group
lodash
classnames
moment
urijs
Tool Configuration
Folder Layout
Store & Reducers
Actions
Props & State
Action Creators
Selectors
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
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"lib": [ "dom", "es5", "es2015.symbol", "es2015.core" ]
}
}
tsconfig.json
Tool Configuration
├───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)
Tool Configuration
Folder Layout
├───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
Tool Configuration
Folder Layout
// 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
// 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
// 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
{
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
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
// 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
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
// 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:
// 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
// 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
// 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
Tool Configuration
Folder Layout
Store & Reducers
Actions
Props & State
Action Creators
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
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
// 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
// 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
Measure in development mode (intensifies issues)
800ms ➡️ 30ms
re-render time
30ms ➡️ 20ms
re-render time
1500ms ➡️ 40ms
dispatch time
20ms ➡️ 5ms
re-render time
40ms ➡️ 10ms
dispatch time
800ms ➡️ 200ms
initial render time
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)