- Notifications
You must be signed in to change notification settings - Fork0
A comprehensive guide to static typing "React & Redux" apps using TypeScript
License
arusantimo/react-redux-typescript-guide
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
A comprehensive guide to static typing "React & Redux" apps using TypeScript.
powered by github ⭐ -star it please if you think it can be usefull and to keep me motivated to maintain this repo relevant with new TypeScript releases
Relevant with TypeScript v2.2 (https://github.com/Microsoft/TypeScript/wiki/Roadmap)
- Complete type safety, without failing to
anytype - Minimize amount of manually typing annotations by leveragingType Inference
- Reduce boilerplate 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
- class component boilerplate
import*asReactfrom'react';typeProps={className?:string,style?:React.CSSProperties,initialCount?:number,};typeState={count:number,};classMyComponentextendsReact.Component<Props,State>{// default props using Property InitializersstaticdefaultProps:Props={className:'default-class',initialCount:0,};// initial state using Property Initializersstate:State={count:this.props.initialCount,};// handlers using Class Fields with arrow functionshandleClick=()=>this.setState({count:this.state.count+1});// lifecycle methods should be declared as normal instance methods and it's finecomponentDidMount(){console.log('Mounted!');}render(){const{ children, initialCount, ...restProps}=this.props;return(<div{...restProps}onClick={this.handleClick}> Clicks:{this.state.count}<hr/>{children}</div>);}};exportdefaultMyComponent;
- stateless component boilerplate
import*asReactfrom'react';typeProps={className?:string,style?:React.CSSProperties,};constMyComponent:React.StatelessComponent<Props>=(props)=>{const{ children, ...restProps}=props;return(<div{...restProps}>{children}</div>);};exportdefaultMyComponent;
- wrap and decorate input Component returning a new Component
- new Component will inherit Props interface through composition from input Component extended with Props of
HOC - using Type Inference to automatically calculate resulting Props interface
- filtering out decorator props and passing only the relevant props through to Wrapped Component
- accepting stateless functional or regular component
// controls/button.tsximport*asReactfrom'react';import{Button}from'antd';typeProps={className?:string,autoFocus?:boolean,htmlType?:typeofButton.prototype.props.htmlType,type?:typeofButton.prototype.props.type,};constButtonControl:React.StatelessComponent<Props>=(props)=>{const{ children, ...restProps}=props;return(<Button{...restProps}>{children}</Button>);};exportdefaultButtonControl;
// decorators/with-form-item.tsximport*asReactfrom'react';import{Form}from'antd';constFormItem=Form.Item;typeDecoratorProps={error?:string,label?:typeofFormItem.prototype.props.label,labelCol?:typeofFormItem.prototype.props.labelCol,wrapperCol?:typeofFormItem.prototype.props.wrapperCol,required?:typeofFormItem.prototype.props.required,help?:typeofFormItem.prototype.props.help,validateStatus?:typeofFormItem.prototype.props.validateStatus,colon?:typeofFormItem.prototype.props.colon,};exportfunctionwithFormItem<GenericProps>(WrappedComponent:React.StatelessComponent<GenericProps>|React.ComponentClass<GenericProps>,){constDecorator:React.StatelessComponent<DecoratorProps&GenericProps>=(props:DecoratorProps)=>{const{ label, labelCol, wrapperCol, required, help, validateStatus, colon, error, ...passThroughProps,}=props;// filtering out empty decorator props in functional styleconstdecoratorProps:DecoratorProps=Object.entries({ label, labelCol, wrapperCol, required, help, validateStatus, colon,}).reduce((definedDecoratorProps:any,[key,value])=>{if(value!==undefined){definedDecoratorProps[key]=value;}returndefinedDecoratorProps;},{});// injecting additional props based on conditionif(error){decoratorProps.help=error;decoratorProps.validateStatus='error';}return(<FormItem{...decoratorProps}hasFeedback={true}><WrappedComponent{...passThroughProps}/></FormItem>);};returnDecorator;}
// components/consumer-component.tsx...import{Button,Input}from'../controls';import{withFormItem,withFieldState}from'../decorators';// you can create more specialized components using decoratorsconstButtonField=withFormItem(Button);// you can leverage function composition to compose multiple decoratorsconstInputFieldWithState=withFormItem(withFieldState(Input));// Enhanced Component will inherit Props type from Base Component with all applied HOC's<ButtonFieldtype="primary"htmlType="submit"wrapperCol={{offset:4,span:12}}autoFocus={true}> Next Step</ButtonField>...<InputFieldWithState{...formFieldLayout}label="Type"required={true}autoFocus={true}fieldState={configurationTypeFieldState}error={configurationTypeFieldState.error}/>...// you could use functional libraries like ramda or lodash to better functional composition like:constInputFieldWithState=compose(withFormItem,withFieldStateInput)(Input);// NOTE: be aware that compose function need to have sound type declaration or you'll lose type inference
NOTE: type inference in
connectfunction type declaration doesn't provide complete type safety and will not leverage Type Inference to automatically calculate resulting Props interface as inHigher-Order Componentexample above
This is something I'm trying to investigate so below solution can be improved even further, please come back later or contribute if have a better solution...
- This solution uses type inference to get Props types from
mapStateToPropsfunction - Minimise manual effort to declare and maintain Props types injected from
connecthelper function - using
returntypeof()helper function, because TypeScript does not support this feature yet (https://github.com/piotrwitek/react-redux-typescript#returntypeof-polyfill) - Real project example:https://github.com/piotrwitek/react-redux-typescript-starter-kit/blob/ef2cf6b5a2e71c55e18ed1e250b8f7cadea8f965/src/containers/currency-converter-container/index.tsx
import{returntypeof}from'react-redux-typescript';import{RootState}from'../../store/types';import{increaseCounter,changeBaseCurrency}from'../../store/action-creators';import{getCurrencies}from'../../store/state/currency-rates/selectors';constmapStateToProps=(rootState:RootState)=>({counter:rootState.counter,baseCurrency:rootState.baseCurrency,currencies:getCurrencies(rootState),});constdispatchToProps={increaseCounter:increaseCounter,changeBaseCurrency:changeBaseCurrency,};// Props types inferred from mapStateToProps & dispatchToPropsconststateProps=returntypeof(mapStateToProps);typeProps=typeofstateProps&typeofdispatchToProps;classCurrencyConverterContainerextendsReact.Component<Props,{}>{handleInputBlur=(ev:React.FocusEvent<HTMLInputElement>)=>{constintValue=parseInt(ev.currentTarget.value,10);this.props.increaseCounter(intValue);// number}handleSelectChange=(ev:React.ChangeEvent<HTMLSelectElement>)=>{this.props.changeBaseCurrency(ev.target.value);// string}render(){const{ counter, baseCurrency, currencies}=this.props;// number, string, string[]return(<section><inputtype="text"value={counter}onBlur={handleInputBlur}.../><selectvalue={baseCurrency}onChange={handleSelectChange}...>{currencies.map(currency=><optionkey={currency}>{currency}</option>,)}</select> ...</section> );}}exportdefaultconnect(mapStateToProps,dispatchToProps)(CurrencyConverterContainer);
This solution is focused on KISS principle, without introducing any abstractions to be as close as possible to common Redux Pattern used in regular JavaScript solutions:
- classic const based types
- very close to standard JS usage
- more boilerplate
- need to export action types and action creators to re-use in other layers like
redux-sagaorredux-observablemodules - using
returntypeof()helper function to infer return type of Action Creators - (https://github.com/piotrwitek/react-redux-typescript#returntypeof-polyfill)
import{returntypeof}from'react-redux-typescript';// Action TypesexportconstINCREASE_COUNTER='INCREASE_COUNTER';exportconstCHANGE_BASE_CURRENCY='CHANGE_BASE_CURRENCY';// Action CreatorsexportconstactionCreators={increaseCounter:()=>({type:INCREASE_COUNTERastypeofINCREASE_COUNTER,}),changeBaseCurrency:(payload:string)=>({type:CHANGE_BASE_CURRENCYastypeofCHANGE_BASE_CURRENCY, payload,}),}// Examplesstore.dispatch(actionCreators.increaseCounter(4));// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.increaseCounter());// OK => { type: "INCREASE_COUNTER" }store.dispatch(actionCreators.changeBaseCurrency());// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.changeBaseCurrency('USD'));// OK => { type: "CHANGE_BASE_CURRENCY", payload: 'USD' }
This solution is using a simple helper factory function to automate creation of typed action creators. With little abstraction we can reduce boilerplate and code repetition, also it is easier to re-use action creators in other layers:
- using helper factory function to automate creation of typed action creators - recommended battle-tested
ActionCreatorfrom (https://github.com/piotrwitek/react-redux-typescript#helpers-v30) - reduced boilerplate and code repetition
- easier to re-use in other layers like
redux-sagaorredux-observablemodules (action creators have type property and also create function, no extra type constant)
import{createActionCreator}from'react-redux-typescript';// Action CreatorsexportconstactionCreators={increaseCounter:createActionCreator('INCREASE_COUNTER'),// { type: "INCREASE_COUNTER" }changeBaseCurrency:createActionCreator('CHANGE_BASE_CURRENCY',(payload:string)=>payload),// { type: "CHANGE_BASE_CURRENCY", payload: string }showNotification:createActionCreator('SHOW_NOTIFICATION',(payload:string,meta?:{type:string})=>payload),};// Examplesstore.dispatch(actionCreators.increaseCounter(4));// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.increaseCounter());// OK => { type: "INCREASE_COUNTER" }actionCreators.increaseCounter.type// "INCREASE_COUNTER"store.dispatch(actionCreators.changeBaseCurrency());// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.changeBaseCurrency('USD'));// OK => { type: "CHANGE_BASE_CURRENCY", payload: 'USD' }actionCreators.changeBaseCurrency.type// "CHANGE_BASE_CURRENCY"store.dispatch(actionCreators.showNotification());// Error: Supplied parameters do not match any signature of call target.store.dispatch(actionCreators.showNotification('Hello!'));// OK => { type: "SHOW_NOTIFICATION", payload: 'Hello!' }store.dispatch(actionCreators.showNotification('Hello!',{type:'warning'}));// OK => { type: "SHOW_NOTIFICATION", payload: 'Hello!', meta: { type: 'warning' }}actionCreators.showNotification.type// "SHOW_NOTIFICATION"
- leveragingDiscriminated Union types
- to guard type and get intellisense of Action payload
- using Partial fromMapped types
- to guard type of
partialStateand restrict superfluous or mismatched props when merging with State
- to guard type of
- using classic const based types
- good for single prop updates or simple state objects
// Stateimport{Action}from'../../types';exporttypeState={readonlycounter:number,readonlybaseCurrency:string,};exportconstinitialState:State={counter:0; baseCurrency:'EUR';};// Reducerexportdefaultfunctionreducer(state:State=initialState,action:Action):State{switch(action.type){caseINCREASE_COUNTER:state.counter=state.counter+1;// no payloadreturnstate;caseCHANGE_BASE_CURRENCY:state.baseCurrency=action.payload;// payload: stringreturnstate;default:returnstate;}}
- if's "block scope" give you possibility to use local variables for more complex state update logic
- better for more complex state objects - using partialState object spread for strongly typed multiple props update - it will ensure that action payload is compatible with reducer state contract - this will guard you from nasty bugs
- introducing optional static
typeproperty onactionCreator- advantage is to get rid of action types constants, as you can check type on action creator itself
import{Action}from'../../types';// StateexporttypeState=Readonly<{counter:number,baseCurrency:string,}>;exportconstinitialState:State={counter:0; baseCurrency:'EUR';};// Reducerexportdefaultfunctionreducer(state:State=initialState,action:Action):State{letpartialState:Partial<State>|undefined;if(action.type===actionCreators.increaseCounter.type){partialState={counter:state.counter+1};// no payload}if(action.type===actionCreators.changeBaseCurrency.type){partialState={baseCurrency:action.payload};// payload: string}returnpartialState!=null ?{ ...state, ...partialState} :state;}
- statically typed global action types -
Action - should be imported in layers dealing with redux actions like: reducers, redux-sagas, redux-observables
import{returntypeof}from'react-redux-typescript';import*asactionCreatorsfrom'./action-creators';constactions=Object.values(actionCreators).map(returntypeof);exporttypeAction=typeofactions[number];
- statically typed global state tree -
RootState - should be imported in connected components providing type safety to Redux
connectfunction
import{reducerascurrencyRatesReducer,StateasCurrencyRatesState,}from'./state/currency-rates/reducer';import{reducerascurrencyConverterReducer,StateasCurrencyConverterState,}from'./state/currency-converter/reducer';exporttypeRootState={currencyRates:CurrencyRatesState;currencyConverter:CurrencyConverterState;};
- creating store - use
RootState(incombineReducersand when providing preloaded state object) to set-upstate object type guard to leverage strongly typed Store instance
import{combineReducers,createStore}from'redux';import{RootState}from'../types';constrootReducer=combineReducers<RootState>({currencyRates:currencyRatesReducer,currencyConverter:currencyConverterReducer,});// rehydrating state on app start: implement here...constrecoverState=():RootState=>({}asRootState);exportconststore=createStore(rootReducer,recoverState(),);
- composing enhancers - example of setting up
redux-observablemiddleware
declarevarwindow:Window&{devToolsExtension:any,__REDUX_DEVTOOLS_EXTENSION_COMPOSE__:any};import{createStore,compose,applyMiddleware}from'redux';import{combineEpics,createEpicMiddleware}from'redux-observable';import{epicsascurrencyConverterEpics}from'./currency-converter/epics';constrootEpic=combineEpics(currencyConverterEpics,);constepicMiddleware=createEpicMiddleware(rootEpic);constcomposeEnhancers=window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__||compose;// store singleton instanceexportconststore=createStore(rootReducer,recoverState(),composeEnhancers(applyMiddleware(epicMiddleware)),);
import'rxjs/add/operator/map';import{combineEpics,Epic}from'redux-observable';import{RootState,Action}from'../types';// check store sectionimport{actionCreators}from'../reducer';import{convertValueWithBaseRateToTargetRate}from'./utils';import*ascurrencyConverterSelectorsfrom'./selectors';import*ascurrencyRatesSelectorsfrom'../currency-rates/selectors';constchangeCurrencyEpic:Epic<Action,RootState>=(action$,store)=>action$.ofType(actionCreators.changeBaseCurrency.type,actionCreators.changeTargetCurrency.type,).map((action:any)=>{constvalue=convertValueWithBaseRateToTargetRate(currencyConverterSelectors.getBaseValue(store.getState()),currencyRatesSelectors.getBaseCurrencyRate(store.getState()),currencyRatesSelectors.getTargetCurrencyRate(store.getState()),);returnactionCreators.changeTargetValue(value);});constchangeBaseValueEpic:Epic<Action,RootState>=(action$,store)=>action$.ofType(actionCreators.changeBaseValue.type,).map((action:any)=>{constvalue=convertValueWithBaseRateToTargetRate(action.payload,currencyRatesSelectors.getBaseCurrencyRate(store.getState()),currencyRatesSelectors.getTargetCurrencyRate(store.getState()),);returnactionCreators.changeTargetValue(value);});constchangeTargetValueEpic:Epic<Action,RootState>=(action$,store)=>action$.ofType(actionCreators.changeTargetValue.type,).map((action:any)=>{constvalue=convertValueWithBaseRateToTargetRate(action.payload,currencyRatesSelectors.getTargetCurrencyRate(store.getState()),currencyRatesSelectors.getBaseCurrencyRate(store.getState()),);returnactionCreators.changeBaseValue(value);});exportconstepics=combineEpics(changeCurrencyEpic,changeBaseValueEpic,changeTargetValueEpic,);
import{createSelector}from'reselect';import{RootState}from'../types';constgetCurrencyConverter=(state:RootState)=>state.currencyConverter;constgetCurrencyRates=(state:RootState)=>state.currencyRates;exportconstgetCurrencies=createSelector(getCurrencyRates,(currencyRates)=>{returnObject.keys(currencyRates.rates).concat(currencyRates.base);},);exportconstgetBaseCurrencyRate=createSelector(getCurrencyConverter,getCurrencyRates,(currencyConverter,currencyRates)=>{constselectedBase=currencyConverter.baseCurrency;returnselectedBase===currencyRates.base ?1 :currencyRates.rates[selectedBase];},);exportconstgetTargetCurrencyRate=createSelector(getCurrencyConverter,getCurrencyRates,(currencyConverter,currencyRates)=>{returncurrencyRates.rates[currencyConverter.targetCurrency];},);
Recommended setup for best benefits from type-checking, with support for JSX and ES2016 features.
- (this setup uses
tslibto externalize ts-helpers from your modules, just add it as prod dependency withnpm i tslib)
{"compilerOptions": {"allowJs":false,"declaration":false,"emitDecoratorMetadata":true,"experimentalDecorators":true,"forceConsistentCasingInFileNames":true,"importHelpers":true,"jsx":"react","lib": ["dom","es2016","es2017.object" ],"target":"es5","module":"es2015","moduleResolution":"node","noEmit":true,"noEmitHelpers":true,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":true,"noImplicitThis":true,"outDir":"out/","pretty":true,"removeComments":true,"sourceMap":true,"strictNullChecks":true },"include": ["**/*" ],"exclude": ["node_modules" ]}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,"member-access":false,"member-ordering": [true, {"order":"statics-first"}],"newline-before-return":false,"no-any":false,"no-inferrable-types": [true],"no-import-side-effect": [true, {"ignore-module":"^rxjs/"}],"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-unused-variable": [true,"react"],"object-literal-sort-keys":false,"only-arrow-functions": [true,"allow-declarations"],"ordered-imports": [false],"prefer-method-signature":false,"prefer-template": [true,"allow-single-concat"],"quotemark": [true,"single","jsx-double"],"triple-equals": [true,"allow-null-check"],"typedef": [true,"parameter","property-declaration","member-variable-declaration"],"variable-name": [true,"ban-keywords","check-format","allow-pascal-case"] }}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.StatelessComponent<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 like this:// containers/container.tsximport{ Select}from'../components';orimportSelectfrom'../components/select';...
Augmenting missing autoFocus Prop on
InputandButtoncomponents inantdnpm package (https://ant.design/)
declare module'../node_modules/antd/lib/input/Input'{exportinterfaceInputProps{autoFocus?:boolean;}}declare module'../node_modules/antd/lib/button/Button'{exportinterfaceButtonProps{autoFocus?:boolean;}}
// reactnpm i -D @types/react @types/react-dom @types/react-redux// redux has types included in it's npm package, no need to use @typesUse
typewhen declaring simple object literal structs e.g. Component Props, Component State, Redux State, Redux Action.In other cases it's more flexible to useinterfaceovertypebecause interfaces can be implemented, extended and merged.Relatedts-lintrule:https://palantir.github.io/tslint/rules/interface-over-type-literal/
No. In TypeScript it is completely unnecessary, you will get a much better free type checking and intellisense at compile time when declaring a "generic type" for component:
React.Component<{ myProp: string }, { myState: number}>, this way you'll never get any runtime errors and get elegant way of describing component external API.
Don't use old-school React class constructors way, prefer to use Property Initializers (first class support in TypeScript)
classMyComponentextendsReact.Component<Props,State>{// default props using Property InitializersstaticdefaultProps:Props={className:'default-class',initialCount:0,};// initial state using Property Initializersstate:State={count:this.props.initialCount,}; ...}
Don't use old-school class methods and function bind way, prefer to use Class Fields with arrow functions (first class support in TypeScript)
classMyComponentextendsReact.Component<Props,State>{// handlers using Class Fields with arrow functionshandleClick=()=>this.setState({count:this.state.count+1});// an exception are lifecycle methods should be declared as normal instance methods and it's fine, because we are extending React ComponentcomponentDidMount(){console.log('Mounted!');} ...}
https://github.com/piotrwitek/react-redux-typescript-starter-kit
About
A comprehensive guide to static typing "React & Redux" apps using TypeScript
Resources
License
Uh oh!
There was an error while loading.Please reload this page.