Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

A comprehensive guide to static typing "React & Redux" apps using TypeScript

License

NotificationsYou must be signed in to change notification settings

arusantimo/react-redux-typescript-guide

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

60 Commits
 
 
 
 
 
 

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

Goals:

Table of Contents


React


Class Component

  • 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

  • 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;

Higher-Order Component

  • wrap and decorate input Component returning a new Component
  • new Component will inherit Props interface through composition from input Component extended with Props ofHOC
  • 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

Redux Connected Component

NOTE: type inference inconnect function type declaration doesn't provide complete type safety and will not leverage Type Inference to automatically calculate resulting Props interface as inHigher-Order Component example 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...

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);

Redux


Actions

KISS Solution

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:

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' }

DRY Solution

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-testedActionCreator from (https://github.com/piotrwitek/react-redux-typescript#helpers-v30)
  • reduced boilerplate and code repetition
  • easier to re-use in other layers likeredux-saga orredux-observable modules (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"

Reducers

  • leveragingDiscriminated Union types
    • to guard type and get intellisense of Action payload
  • using Partial fromMapped types
    • to guard type ofpartialState and restrict superfluous or mismatched props when merging with State

Switch Style

  • 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 Approach

  • 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 statictype property 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;}

Store Types

  • 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 Reduxconnect function
import{reducerascurrencyRatesReducer,StateasCurrencyRatesState,}from'./state/currency-rates/reducer';import{reducerascurrencyConverterReducer,StateasCurrencyConverterState,}from'./state/currency-converter/reducer';exporttypeRootState={currencyRates:CurrencyRatesState;currencyConverter:CurrencyConverterState;};

Create Store

  • creating store - useRootState (incombineReducers and 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 upredux-observable middleware
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)),);

Ecosystem


Async Flow with "redux-observable"

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,);

Selectors with "reselect"

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];},);

Extras

tsconfig.json

Recommended setup for best benefits from type-checking, with support for JSX and ES2016 features.

  • (this setup usestslib to 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"  ]}

tslint.json

Recommended setup is to extend build-in presettslint: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"]  }}

Default and Named Module Exports

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';...

Vendor Types Augmentation

Augmenting missing autoFocus Prop onInput andButton components inantd npm 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;}}

FAQ

- how to install react & redux types?

// reactnpm i -D @types/react @types/react-dom @types/react-redux// redux has types included in it's npm package, no need to use @types

- when to useinterface and whentype to behave consistently?

Usetype when declaring simple object literal structs e.g. Component Props, Component State, Redux State, Redux Action.In other cases it's more flexible to useinterface overtype because interfaces can be implemented, extended and merged.Relatedts-lint rule:https://palantir.github.io/tslint/rules/interface-over-type-literal/

- should I use React.PropTypes?

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.

- how to best declare component instance properties?

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,};  ...}

- how to best declare component handler functions?

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!');}  ...}

Project Examples

https://github.com/piotrwitek/react-redux-typescript-starter-kit

About

A comprehensive guide to static typing "React & Redux" apps using TypeScript

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

[8]ページ先頭

©2009-2025 Movatter.jp