- Notifications
You must be signed in to change notification settings - Fork0
A comprehensive guide to static typing "React & Redux" apps using TypeScript
License
Kryndex/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.
found it usefull, want some more?give it a ⭐
Relevant with TypeScript v2.3 (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
- 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;
- class component boilerplate
import*asReactfrom'react';typeProps={className?:string,style?:React.CSSProperties,initialCount?:number,};typeState={counter:number,};classMyComponentextendsReact.Component<Props,State>{// default props using Property InitializersstaticdefaultProps:Partial<Props>={className:'default-class',};// initial state using Property Initializersstate:State={counter:this.props.initialCount||0,};// lifecycle methods should be declared as normal instance methods and it's finecomponentDidMount(){// tslint:disable-next-line:no-consoleconsole.log('Mounted!');}// handlers using Class Fields with arrow functionsincreaseCounter=()=>{this.setState({counter:this.state.counter+1});};render(){const{ children, initialCount, ...restProps}=this.props;return(<div{...restProps}onClick={this.increaseCounter}> Clicks:{this.state.counter}<hr/>{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;typeBaseProps={};typeHOCProps=FormItemProps&{error?:string;};typeFormItemProps={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<WrappedComponentPropsextendsBaseProps>(WrappedComponent:React.StatelessComponent<WrappedComponentProps>|React.ComponentClass<WrappedComponentProps>,){constHOC:React.StatelessComponent<HOCProps&WrappedComponentProps>=(props)=>{const{ label, labelCol, wrapperCol, required, help, validateStatus, colon, error, ...passThroughProps,}=propsasHOCProps;// filtering out empty decorator props in functional styleconstformItemProps:FormItemProps=Object.entries({ label, labelCol, wrapperCol, required, help, validateStatus, colon,}).reduce((definedProps:any,[key,value])=>{if(value!==undefined){definedProps[key]=value;}returndefinedProps;},{});// injecting additional props based on conditionif(error){formItemProps.help=error;formItemProps.validateStatus='error';}return(<FormItem{...formItemProps}hasFeedback={true}><WrappedComponent{...passThroughPropsasany}/></FormItem>);};returnHOC;}
// 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 declarations 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);
In this approach I focused on a KISS principle, and to stay away of proprietary abstractions like it's commonly found in many TypeScript Redux guides, and to stay as close as possible to a familiar JavaScript usage but still reaping the benefits of static types:
- classic const based types
- very close to standard JS usage
- standard boilerplate
- need to export action types and action creators to re-use in other modules like
redux-sagaorredux-observable
// 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' }
In this an alternative a more DRY approach, I'm introducing a simple helper factory function to automate the creation of typed action creators. The advnatage here is we can reduce some boilerplate and code repetition. It is also easier to re-use action creators in other modules:
- using helper factory function to automate creation of typed action creators - (source code to be revealed)
- less boilerplate and code repetition than KISS Style
- easier to re-use in other modules like
redux-sagaorredux-observable(action creators have type property, type constants are redundant)
DEMO: WIP
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"// truestore.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"// 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: 'Hello!' }store.dispatch(actionCreators.showNotification('Hello!',{type:'warning'}));// OK: { type: "SHOW_NOTIFICATION", payload: 'Hello!', meta: { type: 'warning' }}actionCreators.showNotification.type==="SHOW_NOTIFICATION"// true
Relevant TypeScript Docs references:
- Discriminated Union types
- Mapped types like
Readonly&Partial
// 1a. use readonly modifier to mark state props as immutable and guard with compiler against any mutationsexporttypeState={readonlycounter:number,readonlybaseCurrency:string,};// 1b. if you prefer you can use `Readonly` mapped type as alternative conventionexporttypeStateAlternative=Readonly<{counter:number,baseCurrency:string,}>;// 2. declare initialState using State -> Note: only initialization is allowed with readonly modifiersexportconstinitialState:State={counter:0,baseCurrency:'EUR',};initialState.counter=3;// Error: Cannot assign to 'counter' because it is a constant or a read-only property
- using classic const based types
- good for single prop updates or simple state objects
import{Action}from'../../types';exportdefaultfunctionreducer(state:State=initialState,action:Action):State{switch(action.type){caseINCREASE_COUNTER:return{ ...state,counter:state.counter+1,// no payload};caseCHANGE_BASE_CURRENCY:return{ ...state,baseCurrency:action.payload,// payload: string};default:returnstate;}}
using optional static
typeproperty onactionCreatorfrom helper factory function
- if's "block scope" give you possibility to use local variables for complex state update logic
- by using optional static
typeproperty onactionCreator, we can get rid ofaction types constants, making it easy to create actions and also reuse them to check action type in reducers, sagas, epics etc.
DEMO: WIP
import{Action}from'../../types';// Reducerexportdefaultfunctionreducer(state:State=initialState,action:Action):State{switch(action.type){caseactionCreators.increaseCounter.type:return{ ...state,counter:state.counter+1,// no payload};caseactionCreators.changeBaseCurrency.type:return{ ...state,baseCurrency:action.payload,// payload: string};default:returnstate;}}
By usingpartialState object we can achieve guarded object merge during spread operation - this will ensure that the new state object will not have any excess or mismatched properties and is exactly matched against ourState interface
WARNING: This solution is obligatory at the moment because during spread operation TypeScript compiler will not guard you against superfluous or mismatched props
PS: There is anExact Type proposal to improve this behaviour
import{Action}from'../../types';// BADexportfunctionbadReducer(state:State=initialState,action:Action):State{if(action.type===INCREASE_COUNTER){return{ ...state,counterTypoError:state.counter+1,// OK};// it's a bug! but the compiler will not find it}}// GOODexportfunctiongoodReducer(state:State=initialState,action:Action):State{letpartialState:Partial<State>|undefined;if(action.type===INCREASE_COUNTER){partialState={counterTypoError:state.counter+1,// Error: Object literal may only specify known properties, and 'counterTypoError' does not exist in type 'Partial<State>'.};// now it's showing a typo error correctly}if(action.type===CHANGE_BASE_CURRENCY){partialState={// Error: Types of property 'baseCurrency' are incompatible. Type 'number' is not assignable to type 'string'.baseCurrency:5,};// type errors also works fine}returnpartialState!=null ?{ ...state, ...partialState} :state;}
- 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];
- 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';constrecalculateTargetValueOnCurrencyChange: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.recalculateTargetValue(value);});constrecalculateTargetValueOnBaseValueChange: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.recalculateTargetValue(value);});constrecalculateBaseValueOnTargetValueChange: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.recalculateBaseValue(value);});exportconstepics=combineEpics(recalculateTargetValueOnCurrencyChange,recalculateTargetValueOnBaseValueChange,recalculateBaseValueOnTargetValueChange,);
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.
Addtslibto minimize bundle size:npm i tslib- this will externalize helper functions generated by transpiler and otherwise inlined in your modules
{"compilerOptions":{"baseUrl":"src/",// enables relative imports to root"outDir":"out/",// 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","module":"es2015","moduleResolution":"node","noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitAny":true,"noImplicitReturns":true,"noImplicitThis":true,"strictNullChecks":true,"pretty":true,"removeComments":true,"sourceMap":true},"include":["src/**/*"],"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 - don't install from @typesNo. In TypeScript it is unnecessary, when declaring Props and State types (refer to React components examples) you will get completely free intellisense and compile-time safety with static type checking, this way you'll be safe from runtime errors, not waste time on debugging and get an elegant way of describing component external API in your source code.
Prefered modern style is to use class Property Initializers
classMyComponentextendsReact.Component<Props,State>{// default props using Property InitializersstaticdefaultProps:Props={className:'default-class',initialCount:0,};// initial state using Property Initializersstate:State={counter:this.props.initialCount,}; ...}
Prefered modern style is to use Class Fields with arrow functions
classMyComponentextendsReact.Component<Props,State>{// handlers using Class Fields with arrow functionsincreaseCounter=()=>{this.setState({counter:this.state.counter+1});}; ...}
From practical point of view
interfacetypes will use it's identity when showing compiler errors, whiletypealiases will be always unwinded to show all the nested types it consists of. This can be too noisy when reading compiler errors and I like to leverage this distinction to hide some not important type details in reported type errors
Relatedts-lintrule:https://palantir.github.io/tslint/rules/interface-over-type-literal/
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 "React & Redux" apps using TypeScript
Resources
License
Uh oh!
There was an error while loading.Please reload this page.