- Notifications
You must be signed in to change notification settings - Fork0
A comprehensive guide to static typing in "React & Redux" apps using TypeScript
License
aocsa/react-redux-typescript-guide
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
This guide isNOT about"How to write type declarations for every possible variable and expression to have 100% type covered code and waste a lot of time".
This guide is about"How to write type declarations to only the minimum necessary amount of JavaScript code and still get all the benefits of Static Typing".
found it usefull, want some more?give it a ⭐
- All the examples ported to TypeScript v2.5 and using recent type definitions for
react&reduxTypeScript Changelog - create more strict type definitions for redux
- extend HOC section with more advanced examples#5
- investigate typing patterns for component children#7
This guide is aimed to use--strict flag of TypeScript compiler to provide the best static-typing experience.
Benefits of this setup and static-typing in general include:
- when making changes in your code, precise insight of the impact on the entire codebase (by showing all the references in the codebase for any given piece of code)
- when implementing new features compiler validate all props passed to components or injected by connect from redux store, validation of action creator params, payload objects structure and state/action objects passed to a reducer - showing all possible JavaScript errors)
Additionally static-typing will make processes of improving your codebase and refactoring much easier and give you a confidence that you will not break your production code.
Code examples are generated from the source code inplayground folder. They are tested with TypeScript compiler with the most recent version of TypeScript and relevant type definitions (like@types/react or@types/react-redux) to ensure they are still working with recent definitions.Moreover playground is created is such way, that you can easily clone repository, installnpm dependencies and play around with all the examples from this guide in real project environment without any extra setup.
- Complete type safety with strict null checking, without failing to
anytype - Minimize amount of manually writing type declarations by leveragingType Inference
- Reduce redux boilerplate code withsimple utility functions usingGenerics andAdvanced Types features
- React
- Redux
- Ecosystem
- Async Flow with "redux-observable"
- Selectors with "reselect"
- Forms with "formstate" WIP
- Styles with "typestyle" WIP
- Extras
- FAQ
- Project Examples
- convenient alias:
React.SFC<Props> === React.StatelessComponent<Props>
import*asReactfrom'react';exportinterfaceSFCCounterProps{label:string,count:number,onIncrement:()=>any,}exportconstSFCCounter:React.SFC<SFCCounterProps>=(props)=>{const{ label, count, onIncrement}=props;consthandleIncrement=()=>{onIncrement();};return(<div>{label}:{count}<buttontype="button"onClick={handleIncrement}>{`Increment`}</button></div>);};
SHOW USAGE
import*asReactfrom'react';import{SFCCounter}from'@src/components';letcount=0;constincrementCount=()=>count++;exportdefault()=>(<SFCCounterlabel={'SFCCounter'}count={count}onIncrement={incrementCount}/>);
spread attributes example
import*asReactfrom'react';exportinterfaceSFCSpreadAttributesProps{className?:string,style?:React.CSSProperties,}exportconstSFCSpreadAttributes:React.SFC<SFCSpreadAttributesProps>=(props)=>{const{ children, ...restProps}=props;return(<div{...restProps}>{children}</div>);};
SHOW USAGE
import*asReactfrom'react';import{SFCSpreadAttributes}from'@src/components';exportdefault()=>(<SFCSpreadAttributesstyle={{backgroundColor:'lightcyan'}}/>);
import*asReactfrom'react';exportinterfaceStatefulCounterProps{label:string,}typeState={count:number,};exportclassStatefulCounterextendsReact.Component<StatefulCounterProps,State>{state:State={count:0,};handleIncrement=()=>{this.setState({count:this.state.count+1});};render(){const{ handleIncrement}=this;const{ label}=this.props;const{ count}=this.state;return(<div>{label}:{count}<buttontype="button"onClick={handleIncrement}>{`Increment`}</button></div>);}}
SHOW USAGE
import*asReactfrom'react';import{StatefulCounter}from'@src/components';exportdefault()=>(<StatefulCounterlabel={'StatefulCounter'}/>);
import*asReactfrom'react';exportinterfaceStatefulCounterWithInitialCountProps{label:string,initialCount?:number,}interfaceDefaultProps{initialCount:number,}typePropsWithDefaults=StatefulCounterWithInitialCountProps&DefaultProps;interfaceState{count:number,}exportconstStatefulCounterWithInitialCount:React.ComponentClass<StatefulCounterWithInitialCountProps>=classextendsReact.Component<PropsWithDefaults,State>{// to make defaultProps strictly typed we need to explicitly declare their type//@see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/11640staticdefaultProps:DefaultProps={initialCount:0,};state:State={count:this.props.initialCount,};componentWillReceiveProps({ initialCount}:PropsWithDefaults){if(initialCount!=null&&initialCount!==this.props.initialCount){this.setState({count:initialCount});}}handleIncrement=()=>{this.setState({count:this.state.count+1});};render(){const{ handleIncrement}=this;const{ label}=this.props;const{ count}=this.state;return(<div>{label}:{count}<buttontype="button"onClick={handleIncrement}>{`Increment`}</button></div>);}};
SHOW USAGE
import*asReactfrom'react';import{StatefulCounterWithInitialCount}from'@src/components';exportdefault()=>(<StatefulCounterWithInitialCountlabel={'StatefulCounter'}initialCount={10}/>);
- easily create typed component variations and reuse common logic
- especially useful to create typed list components
import*asReactfrom'react';exportinterfaceGenericListProps<T>{items:T[],itemRenderer:(item:T)=>JSX.Element,}exportclassGenericList<T>extendsReact.Component<GenericListProps<T>,{}>{render(){const{ items, itemRenderer}=this.props;return(<div>{items.map(itemRenderer)}</div>);}}
SHOW USAGE
import*asReactfrom'react';import{IUser}from'@src/models';import{GenericList}from'@src/components';exportconstUserList=classextendsGenericList<IUser>{};exportdefault({ users}:{users:IUser[]})=>(<UserListitems={users}itemRenderer={(item)=><divkey={item.id}>{item.fullName}</div>}/>);
import{connect}from'react-redux';import{RootState}from'@src/redux';import{actionCreators}from'@src/redux/counters';import{SFCCounter}from'@src/components';constmapStateToProps=(state:RootState)=>({count:state.counters.sfcCounter,});exportconstSFCCounterConnected=connect(mapStateToProps,{onIncrement:actionCreators.incrementSfc,})(SFCCounter);
SHOW USAGE
import*asReactfrom'react';import{SFCCounterConnected}from'@src/connected';exportdefault()=>(<SFCCounterConnectedlabel={'SFCCounterConnected'}/>);
import{bindActionCreators}from'redux';import{connect}from'react-redux';import{RootState,Dispatch}from'@src/redux';import{actionCreators}from'@src/redux/counters';import{SFCCounter}from'@src/components';constmapStateToProps=(state:RootState)=>({count:state.counters.sfcCounter,});constmapDispatchToProps=(dispatch:Dispatch)=>bindActionCreators({onIncrement:actionCreators.incrementSfc,},dispatch);exportconstSFCCounterConnectedVerbose=connect(mapStateToProps,mapDispatchToProps)(SFCCounter);
SHOW USAGE
import*asReactfrom'react';import{SFCCounterConnectedVerbose}from'@src/connected';exportdefault()=>(<SFCCounterConnectedVerboselabel={'SFCCounterConnectedVerbose'}/>);
import{connect}from'react-redux';import{RootState}from'@src/redux';import{actionCreators}from'@src/redux/counters';import{SFCCounter}from'@src/components';exportinterfaceSFCCounterConnectedExtended{initialCount:number,}constmapStateToProps=(state:RootState,ownProps:SFCCounterConnectedExtended)=>({count:state.counters.sfcCounter,});exportconstSFCCounterConnectedExtended=connect(mapStateToProps,{onIncrement:actionCreators.incrementSfc,})(SFCCounter);
SHOW USAGE
import*asReactfrom'react';import{SFCCounterConnectedExtended}from'@src/connected';exportdefault()=>(<SFCCounterConnectedExtendedlabel={'SFCCounterConnectedExtended'}initialCount={10}/>);
- function that takes a component and returns a new component
- a new component will infer Props interface from wrapped Component extended with Props of HOC
- will filter out props specific to HOC, and the rest will be passed through to wrapped component
import*asReactfrom'react';import{Omit}from'@src/types/react-redux-typescript';interfaceRequiredProps{count:number,onIncrement:()=>any,}typeProps<TextendsRequiredProps>=Omit<T,keyofRequiredProps>;interfaceState{count:number,}exportfunctionwithState<WrappedComponentPropsextendsRequiredProps>(WrappedComponent:React.ComponentType<WrappedComponentProps>,){constHOC=classextendsReact.Component<Props<WrappedComponentProps>,State>{state:State={count:0,};handleIncrement=()=>{this.setState({count:this.state.count+1});};render(){const{ handleIncrement}=this;const{ count}=this.state;return(<WrappedComponentcount={count}onIncrement={handleIncrement}/>);}};returnHOC;}
SHOW USAGE
import*asReactfrom'react';import{withState}from'@src/hoc';import{SFCCounter}from'@src/components';constSFCCounterWithState=withState(SFCCounter);exportdefault(({ children})=>(<SFCCounterWithStatelabel={'SFCCounterWithState'}/>))asReact.SFC<{}>;
import*asReactfrom'react';constMISSING_ERROR='Error was swallowed during propagation.';interfaceProps{}interfaceState{error:Error|null|undefined,}interfaceWrappedComponentProps{onReset:()=>any,}exportfunctionwithErrorBoundary(WrappedComponent:React.ComponentType<WrappedComponentProps>,){constHOC=classextendsReact.Component<Props,State>{state:State={error:undefined,};componentDidCatch(error:Error|null,info:object){this.setState({error:error||newError(MISSING_ERROR)});this.logErrorToCloud(error,info);}logErrorToCloud=(error:Error|null,info:object)=>{// TODO: send error report to cloud}handleReset=()=>{this.setState({error:undefined});}render(){const{ children}=this.props;const{ error}=this.state;if(error){return(<WrappedComponentonReset={this.handleReset}/>);}returnchildrenasany;}};returnHOC;}
SHOW USAGE
import*asReactfrom'react';import{withErrorBoundary}from'@src/hoc';import{ErrorMessage}from'@src/components';constErrorMessageWithErrorBoundary=withErrorBoundary(ErrorMessage);exportdefault(({ children})=>(<ErrorMessageWithErrorBoundary>{children}</ErrorMessageWithErrorBoundary>))asReact.SFC<{}>;
This pattern is focused on a KISS principle - to stay clear of complex proprietary abstractions and follow simple and familiar JavaScript const based types:
- classic const based types
- very close to standard JS usage
- standard amount of boilerplate
- need to export action types and action creators to re-use in other places, e.g.
redux-sagaorredux-observable
exportconstINCREMENT_SFC='INCREMENT_SFC';exportconstDECREMENT_SFC='DECREMENT_SFC';exporttypeActions={INCREMENT_SFC:{type:typeofINCREMENT_SFC,},DECREMENT_SFC:{type:typeofDECREMENT_SFC,},};// Action CreatorsexportconstactionCreators={incrementSfc:():Actions[typeofINCREMENT_SFC]=>({type:INCREMENT_SFC,}),decrementSfc:():Actions[typeofDECREMENT_SFC]=>({type:DECREMENT_SFC,}),};
SHOW USAGE
importstorefrom'@src/store';import{actionCreators}from'@src/redux/counters';// store.dispatch(actionCreators.incrementSfc(1)); // Error: Expected 0 arguments, but got 1.store.dispatch(actionCreators.incrementSfc());// OK => { type: "INCREMENT_SFC" }
A more DRY approach, introducing a simple factory function to automate the creation of typed action creators. The advantage here is that we can reduce boilerplate and code repetition. It is also easier to re-use action creators in other places because oftype property on action creator containing type constant:
- using factory function to automate creation of typed action creators - (source code to be revealed)
- less boilerplate and code repetition than KISS Style
- action creators have readonly
typeproperty (this make usingtype constantsredundant and easier to re-use in other places e.g.redux-sagaorredux-observable)
import{createActionCreator}from'react-redux-typescript';typeSeverity='info'|'success'|'warning'|'error';// Action CreatorsexportconstactionCreators={incrementCounter:createActionCreator('INCREMENT_COUNTER'),showNotification:createActionCreator('SHOW_NOTIFICATION',(message:string,severity?:Severity)=>({ message, severity}),),};// Examplesstore.dispatch(actionCreators.incrementCounter(4));// Error: Expected 0 arguments, but got 1.store.dispatch(actionCreators.incrementCounter());// OK: { type: "INCREMENT_COUNTER" }actionCreators.incrementCounter.type==="INCREMENT_COUNTER"// truestore.dispatch(actionCreators.showNotification());// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.showNotification('Hello!'));// OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!' }}store.dispatch(actionCreators.showNotification('Hello!','info'));// OK: { type: "SHOW_NOTIFICATION", payload: { message: 'Hello!', severity: 'info }}actionCreators.showNotification.type==="SHOW_NOTIFICATION"// true
Relevant TypeScript Docs references:
- Discriminated Union types
- Mapped types e.g.
Readonly&Partial
Declare reducerState type definition with readonly modifier fortype level immutability
exporttypeState={readonlycounter:number,};
Readonly modifier allow initialization, but will not allow rassignment highlighting an error
exportconstinitialState:State={counter:0,};// OKinitialState.counter=3;// Error, cannot be mutated
This means that readonly modifier does not propagate immutability on nested properties of objects or arrays of objects. You'll need to set it explicitly on each nested property.
exporttypeState={readonlycounterContainer:{readonlyreadonlyCounter:number,mutableCounter:number,}};state.counterContainer={mutableCounter:1};// Error, cannot be mutatedstate.counterContainer.readonlyCounter=1;// Error, cannot be mutatedstate.counterContainer.mutableCounter=1;// No error, can be mutated
There are few utilities to help you achieve nested immutability. e.g. you can do it quite easily by using convenient
ReadonlyorReadonlyArraymapped types.
exporttypeState=Readonly<{countersCollection:ReadonlyArray<Readonly<{readonlyCounter1:number,readonlyCounter2:number,}>>,}>;state.countersCollection[0]={readonlyCounter1:1,readonlyCounter2:1};// Error, cannot be mutatedstate.countersCollection[0].readonlyCounter1=1;// Error, cannot be mutatedstate.countersCollection[0].readonlyCounter2=1;// Error, cannot be mutated
There are some experiments in the community to make a
ReadonlyRecursivemapped type, but I'll need to investigate if they really works
import{combineReducers}from'redux';import{RootAction}from'@src/redux';import{INCREMENT_SFC,DECREMENT_SFC,}from'./';exporttypeState={readonlysfcCounter:number,};exportconstreducer=combineReducers<State,RootAction>({sfcCounter:(state=0,action)=>{switch(action.type){caseINCREMENT_SFC:returnstate+1;caseDECREMENT_SFC:returnstate+1;default:returnstate;}},});
exportconstreducer:Reducer<State>=(state=0,action:RootAction)=>{switch(action.type){caseactionCreators.increment.type:returnstate+1;caseactionCreators.decrement.type:returnstate-1;default:returnstate;}};
- should be imported in layers dealing with redux actions like: reducers, redux-sagas, redux-observables
// RootActionsimport{RouterAction,LocationChangeAction}from'react-router-redux';import{ActionsasCountersActions}from'@src/redux/counters';import{ActionsasTodosActions}from'@src/redux/todos';import{ActionsasToastsActions}from'@src/redux/toasts';typeReactRouterAction=RouterAction|LocationChangeAction;exporttypeRootAction=|ReactRouterAction|CountersActions[keyofCountersActions]|TodosActions[keyofTodosActions]|ToastsActions[keyofToastsActions];
- should be imported in connected components providing type safety to Redux
connectfunction
import{combineReducers}from'redux';import{routerReducerasrouter,RouterState}from'react-router-redux';import{reducerascounters,StateasCountersState}from'@src/redux/counters';import{reducerastodos,StateasTodosState}from'@src/redux/todos';interfaceStoreEnhancerState{}exportinterfaceRootStateextendsStoreEnhancerState{router:RouterState,counters:CountersState,todos:TodosState,}import{RootAction}from'@src/redux';exportconstrootReducer=combineReducers<RootState,RootAction>({ router, counters, todos,});
- creating store - use
RootState(incombineReducersand when providing preloaded state object) to set-upstate object type guard to leverage strongly typed Store instance
import{createStore,applyMiddleware,compose}from'redux';import{createEpicMiddleware}from'redux-observable';import{rootReducer,rootEpic,RootState}from'@src/redux';constcomposeEnhancers=(process.env.NODE_ENV==='development'&&window&&window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__)||compose;functionconfigureStore(initialState?:RootState){// configure middlewaresconstmiddlewares=[createEpicMiddleware(rootEpic),];// compose enhancersconstenhancer=composeEnhancers(applyMiddleware(...middlewares),);// create storereturncreateStore<RootState>(rootReducer,initialState!,enhancer,);}// pass an optional param to rehydrate state on app startconststore=configureStore();// export store singleton instanceexportdefaultstore;
// import rxjs operators somewhere...import{combineEpics,Epic}from'redux-observable';import{RootAction,RootState}from'@src/redux';import{saveState}from'@src/services/local-storage-service';constSAVING_DELAY=1000;// persist state in local storage every 1sconstsaveStateInLocalStorage:Epic<RootAction,RootState>=(action$,store)=>action$.debounceTime(SAVING_DELAY).do((action:RootAction)=>{// handle side-effectssaveState(store.getState());}).ignoreElements();exportconstepics=combineEpics(saveStateInLocalStorage,);
import{createSelector}from'reselect';import{RootState}from'@src/redux';exportconstgetTodos=(state:RootState)=>state.todos.todos;exportconstgetTodosFilter=(state:RootState)=>state.todos.todosFilter;exportconstgetFilteredTodos=createSelector(getTodos,getTodosFilter,(todos,todosFilter)=>{switch(todosFilter){case'completed':returntodos.filter((t)=>t.completed);case'active':returntodos.filter((t)=>!t.completed);default:returntodos;}},);
- Recommended setup for best benefits from type-checking, with support for JSX and ES2016 features
- Add
tslibto minimize bundle size:npm i tslib- this will externalize helper functions generated by transpiler and otherwise inlined in your modules- Include absolute imports config working with Webpack
{"compilerOptions":{"baseUrl":"./",// enables absolute path imports"paths":{// define absolute path mappings"@src/*":["src/*"]// will enable -> import { ... } from '@src/components'// in webpack you need to add -> resolve: { alias: { '@src': PATH_TO_SRC }}},"outDir":"dist/",// target for compiled files"allowSyntheticDefaultImports":true,// no errors on commonjs default import"allowJs":true,// include js files"checkJs":true,// typecheck js files"declaration":false,// don't emit declarations"emitDecoratorMetadata":true,"experimentalDecorators":true,"forceConsistentCasingInFileNames":true,"importHelpers":true,// importing helper functions from tslib"noEmitHelpers":true,// disable emitting inline helper functions"jsx":"react",// process JSX"lib":["dom","es2016","es2017.object"],"target":"es5",// "es2015" for ES6+ engines"module":"commonjs",// "es2015" for tree-shaking"moduleResolution":"node","noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":true,"noImplicitThis":true,"noUnusedLocals":true,"strictNullChecks":true,"pretty":true,"removeComments":true,"sourceMap":true},"include":["src/**/*"],"exclude":["node_modules","src/**/*.spec.*"]}
- Recommended setup is to extend build-in preset
tslint:latest(for all rules usetslint:all)- Add tslint react rules:
npm i -D tslint-reacthttps://github.com/palantir/tslint-react- Amended some extended defaults for more flexibility
{"extends": ["tslint:latest","tslint-react"],"rules": {"arrow-parens":false,"arrow-return-shorthand": [false],"comment-format": [true,"check-space"],"import-blacklist": [true,"rxjs"],"interface-over-type-literal":false,"max-line-length": [true,120],"member-access":false,"member-ordering": [true, {"order":"fields-first" }],"newline-before-return":false,"no-any":false,"no-empty-interface":false,"no-inferrable-types": [true,"ignore-params","ignore-properties"],"no-invalid-this": [true,"check-function-in-method"],"no-null-keyword":false,"no-require-imports":false,"no-switch-case-fall-through":true,"no-trailing-whitespace":true,"no-this-assignment": [true, {"allow-destructuring":true }],"no-unused-variable": [true,"react"],"object-literal-sort-keys":false,"object-literal-shorthand":false,"one-variable-per-declaration": [false],"only-arrow-functions": [true,"allow-declarations"],"ordered-imports": [false],"prefer-method-signature":false,"prefer-template": [true,"allow-single-concat"],"semicolon": [true,"ignore-interfaces"],"quotemark": [true,"single","jsx-double"],"triple-equals": [true,"allow-null-check"],"typedef": [true,"parameter","property-declaration"],"variable-name": [true,"ban-keywords","check-format","allow-pascal-case","allow-leading-underscore"] }}Most flexible solution is to use module folder pattern, because you can leverage both named and default import when you see fit.
Using this solution you'll achieve better encapsulation for internal structure/naming refactoring without breaking your consumer code:
// 1. in `components/` folder create component file (`select.tsx`) with default export:// components/select.tsxconstSelect:React.SFC<Props>=(props)=>{...exportdefaultSelect;// 2. in `components/` folder create `index.ts` file handling named imports:// components/index.tsexport{ defaultasSelect}from'./select';...// 3. now you can import your components in both ways named (internal) or default (public):// containers/container.tsximport{ Select}from'@src/components';orimportSelectfrom'@src/components/select';...
Strategies to fix various issues coming from broken vendor type declaration files (*.d.ts)
- Augumenting library internal type declarations - using relative import resolution
// added missing autoFocus Prop on Input component in "antd@2.10.0" npm packagedeclare module'../node_modules/antd/lib/input/Input'{exportinterfaceInputProps{autoFocus?:boolean;}}
- Augumenting library public type declarations - using node module import resolution
// fixed broken public type declaration in "rxjs@5.4.1" npm packageimport{Operator}from'rxjs/Operator';import{Observable}from'rxjs/Observable';declare module'rxjs/Subject'{interfaceSubject<T>{lift<R>(operator:Operator<T,R>):Observable<R>;}}
// reactnpm i -D @types/react @types/react-dom @types/react-redux// redux has types included in it's npm package - don't install from @typesNo. When using TypeScript it is an unnecessary overhead, when declaring IProps and IState interfaces, you will get complete intellisense and compile-time safety with static type checking, this way you'll be safe from runtime errors and you will save a lot of time on debugging. Additional benefit is an elegant and standarized method of documenting your component external API in the source code.
From practical side, using
interfacedeclaration will display identity (interface name) in compiler errors, on the contrarytypealiases will be unwinded to show all the properties and nested types it consists of. This can be a bit noisy when reading compiler errors and I like to leverage this distinction to hide some of not so important type details in errors
Relatedts-lintrule:https://palantir.github.io/tslint/rules/interface-over-type-literal/
Prefered modern style is to use class Property Initializers
classStatefulCounterWithInitialCountextendsReact.Component<Props,State>{// default props using Property InitializersstaticdefaultProps:DefaultProps={className:'default-class',initialCount:0,};// initial state using Property Initializersstate:State={count:this.props.initialCount,}; ...}
Prefered modern style is to use Class Fields with arrow functions
classStatefulCounterextendsReact.Component<Props,State>{// handlers using Class Fields with arrow functionshandleIncrement=()=>{this.setState({count:this.state.count+1});}; ...}
https://github.com/piotrwitek/react-redux-typescript-starter-kit
https://github.com/piotrwitek/react-redux-typescript-webpack-starter
About
A comprehensive guide to static typing in "React & Redux" apps using TypeScript
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Releases
Packages0
Languages
- TypeScript60.4%
- Other38.8%
- HTML0.8%