Typesafe React Redux hooks
Tomas Fagerbekk
Posted on July 1, 2020
Going from mapStateToProps
and mapStateToDispatch
to useDispatch, useSelector
or custom hooks: What's the benefits? Does typing inference work?
The code below exists at github.com/tomfa/redux-hooks, and I'll be referencing commits as I go along.
Plan
Set up a React Redux with Typescript
Implement some redux state, and implement UI using MapStateToProps and MapDispatchToProps. (Referenced to as MapXToProps from now on).
Swap to using built-in Redux hooks.
Swap to custom hooks.
Part I: Set up React Redux with Typescript
Install React with Redux
npx create-react-app redux-hooks --template redux
And then run it:
yarn start
Nice. The browser should show you something ala the above.
Add typescript
Add types and the compiler (666f61)
yarn add -D \
typescript \
@types/node \
@types/react \
@types/react-dom \
@types/jest \
@types/react-redux
And rename all .js(x)
to .ts(x)
files (54bfd7). You could do this manually (there's only ~10 files), or with the bash snippet here:
for x in $(find ./src -name \*.js\*); do
mv $x $(echo "$x" | sed 's/\.js/.ts/')
done
Ok, sweet. Let's add a tsconfig.json
with e.g. the following contents (8b76f82):
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}
This config above is from react-starter --template typescript:
General hygenic setup
Part II: Add some state
The app is a simple Chat app, taken from Recipe: Usage with TypeScript. It consists of two UI components:
- ChatInput
- ChatHistory
Together, they make a dummy chat app that uses Redux. Below is the ChatHistory component:
import * as React from "react";
import { connect } from "react-redux";
import { RootState } from "../../store";
import "./ChatHistory.css";
interface OwnProps {}
type DispatchProps = {};
type StateProps = ReturnType<typeof mapStateToProps>;
type Props = OwnProps & DispatchProps & StateProps;
const ChatHistory: React.FC<Props> = ({ messages }) => (
<div className="chat-history">
{messages.map((message) => (
<div className="message-item" key={message.timestamp}>
<h3>From: {message.user}</h3>
<p>{message.message}</p>
</div>
))}
</div>
);
const mapStateToProps = (state: RootState, ownProps: OwnProps) => ({
messages: state.chat.messages,
});
export default connect<StateProps, DispatchProps, OwnProps, RootState>(
mapStateToProps
)(ChatHistory);
Diff e877b50...6efc2a2 shows the whole code for these components.
Typing inference works great!
- Automatic property inference with these lines of boilerplate (in each connected component):
// ../ChatInput.tsx
interface OwnProps {}
type DispatchProps = ReturnType<typeof mapDispatchToProps>;
type StateProps = ReturnType<typeof mapStateToProps>;
type Props = DispatchProps & StateProps & OwnProps;
...
export default connect<
StateProps,
DispatchProps,
OwnProps,
RootState
>(
mapStateToProps,
mapDispatchToProps,
)(ChatHistory);
- Automatic store type inference with this:
// ../store/index.ts
export type RootState = ReturnType<typeof rootReducer>;
// ../ChatHistory.tsx
import { RootState } from "../../store";
const mapStateToProps = (state: RootState, ...
TypeScript tells me if my store value has the wrong type when added to JSX, and also when passing the wrong input type into action payloads. It works neatly!
One frequently mentioned drawback of Redux is the amount of boilerplate. Typing definitely adds to this with connected components. Let's see how hooks simplifies it.
Part III: Converting to hooks
ChatHistory: replace with hooks
// import { useSelector } from "react-redux";
// import { RootState as S } from "../../store";
const messages = useSelector((state: S) => state.chat.messages);
Diff: 1310a50
ChatHistory only used State. I feel the readability of the code is better, and it's also shorter, going from 29 to 21 lines. Almost zero boilerplate.
ChatInput: replace with hooks
Diff: 988ee06
ChatInput went from 70 to 57 lines, with a total codediff of -13 lines (being the only changed file). I still decided to keep the UI-related logic outside of hooks, so the difference isn't as large as it could be.
Again, I think the diff makes the component read better. Almost all the boilerplate code is gone! Even without most of the typing-related code, the inference is intact.
Part IV: Replace hooks with custom hooks
Diff: 1c5d82f
ChatInput goes from 57 to 34 lines, but since we're adding two new hooks files, we end up with a +14 code line change compared with built-in hooks.
With custom hooks, we can rename things as we please, and all we end up with (relating to redux) is:
const { inputValue, setInputValue, submit } = useChatInput();
const { userName } = useAuth();
It does require us to add (and maintain) extra "hooks files", but I think it reads very easily.
The separation of concerns is clear, with clean ability to reuse logic across components. Though this commit is some extra lines of code, it could become fewer if the hooks are reused; even just once.
Summary
The overall change from MapXToProps to using built-in hooks can be seen in the diff c22c184...988ee06
The change from MapToProps to using custom hooks can be seen in the diff 1310a50...1c5d82f
Type checking was preserved throughout the changes.
Code size decreased when changing to built-in hooks.
Code size was equal when changing to custom hooks (before any reuse).
Component with hooks will rerender when parent rerenders, unlike with MapXToProps. However, this can easily be fixed with
React.useMemo
wrapping the component.
Overall, I do not see good reasons to keep using MapXToProps. Hooks seem more consise and readable.
Tell me if I've missed something :)
Feature image is by Anne Nygård
Posted on July 1, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 26, 2024