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

Kryndex/react-redux-typescript-guide

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 
 
 
 
 
 
 

Repository files navigation

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

found it usefull, want some more?give it a ⭐

Goals:

Table of Contents


React


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;

Class Component

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

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

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 Style

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 likeredux-saga orredux-observable

DEMO:TypeScript Playground

// 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 Style

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 likeredux-saga orredux-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

Reducers

Relevant TypeScript Docs references:

Declare reducerState type to achieve compile-time immutability

DEMO:TypeScript Playground

// 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

switch style reducer

  • using classic const based types
  • good for single prop updates or simple state objects

DEMO:TypeScript Playground

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

if's style reducer

using optional statictype property onactionCreator from helper factory function

  • if's "block scope" give you possibility to use local variables for complex state update logic
  • by using optional statictype property 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;}}

Spread operation with Exact Types check to guard against excess or mismatched props

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

DEMO:TypeScript Playground

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

Store Types

  • Action - statically typed global action types

  • 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];
  • RootState - statically typed global state tree

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

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.
Addtslib to 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"]}

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 - don't install from @types

- should I still use React.PropTypes in TS?

No. 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.

- how to best initialize class instance or static properties?

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

- how to best declare component handler functions?

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

- differences betweeninterface declarations andtype aliases

From practical point of viewinterface types will use it's identity when showing compiler errors, whiletype aliases 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-lint rule:https://palantir.github.io/tslint/rules/interface-over-type-literal/


Project Examples

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

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

[8]ページ先頭

©2009-2025 Movatter.jp