There has been a lot of developer buzz around TypeScript, ReactJS and Redux for many years already and the interest does not shows signs of subsiding. Here are my reflections on the lessons learned on writing type-safe and deterministic applications with the combo. A demo app and its source code are provided.
In this blog post I will show you how you can implement a strongly-typed Redux/ReactJS app in TypeScript with the bare minimum of type-annotations and external dependencies. The only requirement is that we will be using TypeScript version 2.8 or higher.
There are already countless introductory tutorials online on setting up TypeScript in conjunction with Redux and ReactJS. Suffice it to say that these tools allow us to write applications with both deterministic and type-safe state management.
I would like to reflect on the deeper lessons learned over the years. In particular, I will focus on types and type constructors. We are going to think about what types we need to define and how we can derive all the other types from the more primitive types.
I hope the reader is looking for answers beyond the “getting started” tutorials, perhaps after struggling a bit with TypeScript compiler errors or after realizing that overtyping can actually just slow you down and increase the maintenance burden of your app. In contrast, a codebase where all the components, reducers and the like will be automatically aware of the type changes in your action objects is a huge improvement to plain JavaScript.
Yet another Notes app
Domain
Let us write the infamous TODO app yet again. The point here is not to show how a full-fledged app should be organized, but work with an elaborate enough codebase that looks like this:
src/
├── domain.d.ts
├── index.tsx
├── store
│ ├── Actions.ts
│ ├── Reducer.ts
│ └── Store.ts
├── ui
│ ├── Editor.tsx
│ |── ... more UI components
└── utils.ts
The starting place is the domain model. We define how todo notes look like in
domain.d.ts
, an ambient
module
from which the type definitions spread to all other modules without explicit
importing.
// domain.d.ts
type UUID = string;
interface TodoContent<T extends string> {
type: T;
id: UUID;
content: string;
createdAt: string; // iso 8601
modifiedAt: string; // iso 8601
}
type Todo =
| TodoContent<"New">
| TodoContent<"Editing">
| TodoContent<"NotDone">
| TodoContent<"Done">;
type NilTodo = Partial<TodoContent<"Nil">>;
type Maybe<T extends Todo, Nothing = NilTodo> = T | Nothing;
Pretty standard stuff following general guidelines. Domain objects should be
serializable and have unique ids. We employ union
types that “close over” the
lifecycle of a todo note. We also define a null
object NilTodo
to denote a
missing Todo
object and a Maybe
type constructor to deal with situations
where a todo may or may not be defined.
As for the state shape, I follow the recommended approach of normalizing data like so:
// domain.d.ts
interface AppState {
map: { [uuid: UUID]: Todo };
selected: Maybe<Todo>;
order: UUID[];
}
The application state shape should not be designed with sole focus on the needs of the user interface. The user interfaces changes rapidly during the development process, but the above model is stable and general. Always prefer stable and general solutions when possible.
The NilTodo
object is implemented as an immutable singleton and we provide a
type guard function to disambiguate between a Maybe<Todo>
and a Todo
.
// utils.ts
const nil: NilTodo = Object.freeze({
type: "Nil"
});
function isTodo(todo: Maybe<Todo>): todo is Todo {
return todo !== nil;
}
Pretty cheap solution for getting rid of null pointer dereferences forever.
There cannot be a null
in play if we do not put it there!
In general, the types and data structures in your app are yours and have the greatest effect on the overall ease of implementation of your app; no external library can design the types and data structures for you.
Actions
Let us proceed to defining behavior in Actions.ts
. There is a nifty trick that
makes Redux objects play nice with TypeScript, just wait for it. The gist is to
capture the type
property of all the action objects as a type using a dummy
helper function called action
.
// Actions.ts
function action<T extends string, P, M = {}>(type: T, payload?: P, meta?: M) {
return { type, payload, meta };
}
For example, we may define an action creator for selecting a todo like so:
// Actions.ts
export const SELECT_TODO = "SELECT_TODO";
function select(todo: Todo) {
return action(SELECT_TODO, todo);
}
// ... more action creators
export default {
...,
select
};
I like to package my action creators precisely in this format. The same module exports the action types as string constants and the default export is an object whose properties are the action creators proper.
There are usually only a handful of core action creators per domain object, think CRUD. However, I usually also write convenience action creators on top of the core ones:
// Actions.ts
function edit(todo: Maybe<Todo>, update: Partial<Todo>) {
const modifiedAt = new Date().toISOString();
const type = update.type || "Editing";
const edited = isTodo(todo) ? { ...todo, ...update, modifiedAt, type } : todo;
return action(EDIT_TODO, edited);
}
function markDone(todo: Maybe<Todo>) {
return edit(todo, { type: "Done" });
}
function unmark(todo: Maybe<Todo>) {
return edit(todo, { type: "NotDone" });
}
export default {
...,
edit,
markDone,
unmark
};
The extra work pays dividends later on in the user interface where we want to have as little noise as possible.
The trick
And now here comes the trick! We can now derive all the other action-related
types programmatically in Store.ts
.
// Store.ts
import actionCreators, * as actionTypes from "./Actions";
/* Redux types */
type ActionType = keyof typeof actionTypes;
type ActionCreators = typeof actionCreators;
type ActionDispatcher = { dispatch: ActionCreators };
type ActionUnion = ReturnType<ActionCreators[keyof ActionCreators]>;
type Action<T = ActionType> = Defined<Filter<ActionUnion, { type: T }>>;
export { Action, ActionDispatcher, ActionCreators };
I can still remember the moment when I figured out the magic of
type Action<T = ActionType> = Defined<Filter<ActionUnion, { type: T }>>;
Took me a good day and I am proud that I did it on my own, even though I suspect that the same has been discovered also elsewhere. The definition depends on two helper type constructors that read:
type Filter<T, U> = T extends U ? T : never;
type Defined<T> = { [P in keyof T]-?: Defined<NonNullable<T[P]>> };
The Action<T>
type allows you to generate the action object type from the
type
property, e.g. Action<"SELECT_TODO">
is equivalent to { type: "SELECT_TODO"; payload: Todo; }
. This is very handy as now TypeScript can infer
the payload shape without type casting.
Consequently, a stub of the reducer function looks like this:
import { Reducer } from "redux";
import { Action } from "./Store";
import { nil } from "../utils";
const initialState: AppState = {
map: {},
selected: nil,
todos: [],
};
const reducer: Reducer<AppState, Action> = (state = initialState, action) => {
switch (action.type) {
// ... Handle other actions
case "SELECT_TODO":
// action.payload is `Todo`
return {
...state,
selected: action.payload
};
default:
return state;
}
};
export default reducer;
Plumbing
OK, so we have a domain model, action creators and a reducer set up. Now we
package our Redux store related code in Store.ts
and export relevant pieces
for the user interface components.
I do not want to have any Redux dependecies or boilerplate in the user interface. Boilerplate code just plain sucks and Redux is ultimately only a tool; what is more fundamental is the contract between application state and the component props.
That is why I implement the Store.ts
module as a facade to the redux
and
react-redux
libraries and have the user interface components point to it.
// Store.ts
/* Store facade */
import { connect as _connect, Provider, MapStateToProps } from "react-redux";
import { createStore, bindActionCreators } from "redux";
interface Config {
// ... Store config
}
type Selector<StateProps, OwnProps = {}> = MapStateToProps<
StateProps,
OwnProps,
AppState
>;
function configureStore(config: Config) {
// wire middlewares and the like based on config
return createStore(reducer);
}
function connect<S, O = {}>(selector?: Selector<S, O>) {
return _connect(selector || null, dispatch => ({
dispatch: bindActionCreators(actionCreators, dispatch),
}));
}
I supercharge the connect()
function from react-redux
by teaching it how
AppState
looks like. I also export a wrapper for the createStore()
function
that allows us to configure how the Redux store is instantiated based on some
configuration object.
What is perhaps a bit more controversial is that I like to inject all the action
creators in bulk to the user interface behind an object named dispatch
. That
is, to invoke an action creator foo()
, you’d simply write:
import { ActionDispatcher, connect } from "../store/Store";
interface Props extends ActionDispatcher {
// some other props
}
const Component = ({ dispatch }) => (
<button onClick={dispatch.foo} />
);
export default connect()(Component);
The counter argument here is that it one should only import that what is needed. I have nonetheless found the above approach far more scalable than explicit plumbing, especially as we have strong-typing out of the box.
In general, a far graver mistake is to introduce business logic into the user interface. This manifests itself as components where action creators are composed and there is imperative code. Remember that we are merely binding actions to buttons and the like. Redux middleware is the place where we do “processes” and impure stuff and the reducers should be smart instead of merely aggregating data.
The user interface
With the store implemented, the rest is just putting the pieces together. The goal here is to end up with essentially a bunch of XML. If the whole user interface is just setting values to attributes and does not contain any logic to speak of, the user interface can be merely defined. This is fitting as we construct it using two declarative languages, HTML and CSS.
The outer shell for the app is simply:
const AppShell: React.FC = ({ children }) => (
<main className="flex flex-row items-stretch">
<aside
tabIndex={0}
style={{ maxWidth: "30vw", minWidth: "20vw" }}
className="pa3 pt1 h-resizable bg-light-gray br b--light-silver"
>
<header>
<a href="/">
<h4>{document.title}</h4>
</a>
</header>
</aside>
<section id="app" className="flex flex-auto flex-column">
{children}
</section>
</main>
);
function App(): React.ReactElement {
return (
<Provider store={configureStore()}>
<AppShell>
<Screen />
</AppShell>
</Provider>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
The app only has one screen Screen
and its definition looks like:
import * as React from "react";
import { connect, AppState, ActionDispatcher } from "../store/Store";
import Menu from "./Menu";
import Editor from "./Editor";
import Icon from "./Icons";
import { isTodo, canMarkDone } from "../utils";
interface Props extends ActionDispatcher {
todos: Todo[];
selected: Maybe<Todo>;
}
const props = ({ todos, selected }: AppState) => {
return { todos, selected };
};
const Toolbar: React.FC = ({ children }) => {
return (
<nav className="pa3">
<div className="flex flex-row items-start">{children}</div>
</nav>
);
};
const Screen: React.FC<Props> = ({ dispatch, selected, todos }) => {
const markable = canMarkDone(selected);
const markAction = markable ? dispatch.markDone : dispatch.unmark;
return (
<>
<Toolbar>
<Icon
name="Compose"
title="Create a note"
onClick={() => {
dispatch.create();
}}
/>
<Icon
name={markable ? "Check" : "Refresh"}
title={markable ? "Mark as done" : "Undo"}
disabled={!selected.content}
onClick={() => {
markAction(selected);
}}
/>
<Icon
name="Trash"
title="Remove a note"
disabled={!isTodo(selected)}
onClick={() => {
dispatch.removeTodo(selected);
}}
/>
</Toolbar>
<div className="flex flex-auto flex-row items-stretch">
<Menu tabIndex={1} todos={todos}>
{todo => (
<Menu.Item
selected={todo == selected}
todo={todo}
onClick={() => todo !== selected && dispatch.select(todo)}
/>
)}
</Menu>
<Editor
tabIndex={2}
todo={selected}
onStopEdit={() => {
dispatch.unmark(selected);
}}
onEdit={(content: string) => {
dispatch.edit(selected, { content });
}}
></Editor>
</div>
</>
);
};
export default connect(props)(Screen);
The two auxiliary components Editor
and Menu
read
// Editor.tsx
import * as React from "react";
import { useFocus } from "./Effects";
import { isTodo } from "../utils";
interface Props extends React.HTMLProps<HTMLTextAreaElement> {
todo: Maybe<Todo>;
onEdit(content: string): void;
onStopEdit(): void;
}
const Editor: React.FC<React.HTMLProps<HTMLTextAreaElement>> = ({
autoFocus,
readOnly,
title,
value,
onChange,
onBlur,
}) => {
const textArea: React.RefObject<HTMLTextAreaElement> = React.useRef(null);
React.useEffect(useFocus(textArea), [autoFocus]);
return (
<>
<p className="tc fw1 f5">{title}</p>
<textarea
readOnly={readOnly}
ref={textArea}
value={value}
onChange={onChange}
onBlur={onBlur}
className="input-reset bn mh5 flex-auto no-resizable"
/>
</>
);
};
const Empty: React.FC = () => {
return <p className="flex-v-margin-align tc f2 light-silver">No note selected</p>;
};
const TodoEditor: React.FC<Props> = ({ todo, onStopEdit, onEdit, tabIndex }) => {
const onChange = ({ target }: React.FormEvent) => {
const textarea = target as HTMLTextAreaElement;
onEdit(textarea.value);
};
const Content = isTodo(todo) ? Editor : Empty;
return (
<div tabIndex={tabIndex} className="flex flex-column flex-auto">
<Content
readOnly={todo.type == "Done"}
autoFocus={todo.type === "New"}
title={new Date(todo.createdAt || "").toLocaleString()}
value={todo.content}
onBlur={onStopEdit}
onChange={onChange}
/>
</div>
);
};
export default TodoEditor;
// Menu.tsx
import * as React from "react";
import FlipMove from "react-flip-move";
import { parse, timestamp, isTodo } from "../utils";
interface Props extends React.HTMLProps<HTMLUListElement>{
todos: Todo[];
children: React.FC<Todo>;
}
interface ItemProps extends React.HTMLProps<HTMLAnchorElement> {
todo: Todo;
}
interface StaticProps {
Item: React.FC<ItemProps>;
}
const TodoMenuItem: React.FC<ItemProps> = ({ selected, onClick, todo }) => {
const { title, details } = parse(todo);
const state = todo.type.toLowerCase();
return (
<a
href="#"
onClick={onClick}
className={selected ? `selected todo-item ${state}` : `todo-item ${state}`}
>
<div className="noselect flex-auto f6 ph3 pv2 bb b--light-silver-o">
<strong className="db mv1">{title}</strong>
<p className="mv1 truncate">
{timestamp(todo)}
<span className="fw3">{details}</span>
</p>
</div>
</a>
);
};
const TodoMenu: React.FC<Props> & StaticProps = ({ children, todos, ...props }) => {
return (
<ul
style={{ width: "20vw" }}
className="todo-list list h-resizable ma0 pa0 br b--light-silver-o"
{...props}
>
<FlipMove
leaveAnimation="accordionVertical"
enterAnimation="accordionVertical"
typeName={null}
>
{todos.filter(isTodo).map(todo => (
<li key={todo.id}>{children(todo)}</li>
))}
</FlipMove>
</ul>
);
};
TodoMenu.Item = TodoMenuItem;
export default TodoMenu;
You may study the source code at my GitHub repository. That’s it. You may view the end result online here, pretty slick, aye!? Hopefully you found this blog useful and good luck for your next frontend adventure.