Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Introduction to Redux with TypeScript

Introduction to Redux with TypeScript

Elegance always matters - especially when crafting software - and patterns exist precisely as inspiring principles that lead us to methodically tackling complexity and creating clean architectures.

State management is one of the most essential and nuanced problems in the IT domain - and Redux, inspired by Elm and Flux, elegantly mixes beautiful concepts such as events and reducers to provide a simple and effective solution via a functional approach.

Gianluca Costa

February 14, 2023
Tweet

More Decks by Gianluca Costa

Other Decks in Programming

Transcript

  1. Foreword Elegance always matters - especially when crafting software -

    and patterns exist precisely as inspiring principles that lead us to methodically tackling complexity and creating clean architectures. State management is one of the most essential and nuanced problems in the IT domain - and Redux, inspired by Elm and Flux, elegantly mixes beautiful concepts such as events and reducers to provide a simple and effective solution via a functional approach. 2
  2. About this presentation Our journey stems from my passion for

    the concepts at the heart of Redux: we are going to discover, with no claim of completeness, how Redux can be a brilliant tool for state management - especially when combined with the static type checking provided by TypeScript. I have also created a few minimalist code projects - available in the companion GitHub repository - to showcase different aspects of the Redux ecosystem. This work is designed to be a concise reference: should you need a more gradual approach, live coding sessions or real-life projects, please consider online courses. For the latest and most complete version of the documentation, please refer to Redux's official website. 3
  3. Overview The main parts of this presentation can be briefly

    summarized as follows: 1. Enter Redux: introductory considerations before sailing 2. Essential building blocks: core aspects of Redux, packaged as redux on NPM 3. Redux Toolkit: simplified development with the @reduxjs/toolkit package 4. React Redux: to elegantly combine React and Redux 5. Conclusion: parting thoughts 4
  4. What is Redux? « Redux is a pattern and library

    for managing and updating application state » Minimalist library providing just a core implementation Redux does not depend on a specific major library or framework, such as React or Angular: it can be referenced by any JavaScript project, including backend projects and potentially even command-line applications The ecosystem provides a wide variety of extensions and developer tools 6
  5. Global state in one store The core idea is a

    single, centralized store containing the global state: Any modification to the state can only be performed indirectly, by sending messages to the store Each message is processed according to specific business rules and creates a new state for the store 7
  6. Why use Redux? Single source of truth - the store

    Trackable modifications Expressive code Scalable, message-based architecture Simplified data access in complex UIs Time-travel debugging 8
  7. Vibrant ecosystem The very heart of Redux consists of the

    redux package, designed to be extensible Redux Toolkit, packaged as @reduxjs/toolkit , exports sensible defaults and minimalist constructs React Redux provides seamless integration with React Redux DevTools: time-travelling debugger and other tools ...and much more! Redux is designed to be extensible! 10
  8. Not every app needs Redux Every architectural choice has drawbacks;

    in the case of Redux: boilerplate code to support the architecture - especially without Redux Toolkit additional layers of indirection developers must choose what part of the state is global - typically in React apps the learning curve, which could be initially demanding 11
  9. Best scenarios for Redux The more of the following factors

    are present in your application, the more effective Redux will be: Multiple, heterogeneous sources triggering state updates Chaotic state fragmentation at different levels Complex, strongly domain-related update logic Message-/Event-driven architecture Vast, shared codebase 12
  10. The store « The store is an object that holds

    the application's state tree. There should only be one single store in a Redux app » The store provides just a basic set of methods - in particular: getState() : returns the current state dispatch(action) : sends an action (a message) to the store, always triggering the computation of the next state. There's no other way to change the state in the store subscribe(listener) : adds a listener that gets notified right after the computed next state becomes the store state. Returns a function to cancel the subscription 16
  11. The state must be serializable The state contained in the

    store can range from a primitive value up to a complex object graph having arbitrary depth. One should store only serializable objects as well as primitive values - to ensure portability in a variety of situations such as persistence and debugging. In particular, the state should definitely not contain: class instances functions promises It is usually recommended that entities - i.e., domain objects having an ID, be stored in a dedicated state branch and referenced by ID from within other branches 17
  12. Store creators « A store creator is a function that

    creates a Redux store » The essential store creator is createStore() , provided by Redux and supporting 3 args: reducer : the reducer (also known as root reducer) used by the store - a function returning the new state whenever an action is dispatched initial_state (optional): if missing or set to undefined, the reducer will use the default value of its state parameter enhancer (optional): higher-order function that takes a store creator and returns a modified store creator; the most common enhancer is applyMiddleware() , to apply middleware extensions 18
  13. Deprecation warning for createStore() createStore() is a basic store creator

    - effective for introductory examples. It is nowadays deprecated, as the recommended store creator has become configureStore() - provided by Redux Toolkit and described in the related section of this presentation. Existing code should still compile until future major versions - but it is definitely advisable to start adopting the new approach 19
  14. Just a single store? « It is possible to create

    multiple distinct Redux stores, but the intended pattern is to have only a single store » Considering that the Redux ecosystem revolves around the one-store principle, it is generally advisable to adhere as much as possible to this guideline 20
  15. Actions « An action is a plain object that represents

    an intention to change the state » Actions can be arbitrary JavaScript objects, with just one requirement: they must have a type field, that should be a specific string (not a symbol), such as in: const ADD_BEAR = "bears/add"; interface AddBearAction { type: typeof ADD_BEAR; name: string; } As shown above, type constants often has / characters to conventionally organize the actions into logical trees; however, Redux code is usually based on mere string equality 21
  16. Action creators « An action creator is a function creating

    a specific action type » An action creator is conventionally the only way to create an action - as it ensures that the action's fields are correctly initialized. This is a typical pattern for creating the action seen in the previous slide: export function addBear(name: string): AddBearAction { return { type: ADD_BEAR, name }; } 22
  17. Feature-related action type It is recommended to define a dedicated

    type unifying all the interfaces related to the same set of actions in the domain; for example: type BearAction = AddBearAction | ClearBearsAction; 23
  18. Reducers « A reducer is a pure function that receives

    the current store state and an action, and must return the new state » Reducers are pure functions - they cannot have side effects. In particular: reducers cannot modify their arguments - especially the state reducers cannot depend on the context - e.g, reading/writing global variables reducers cannot perform side-effects (deleting files, calling impure functions, ...) reducers cannot contain async logic - such as REST API calls reducers must be deterministic they cannot rely on any external environment or even random number generators or Date.now() - use action creators instead 24
  19. Writing a basic reducer export function bearReducer( state: ReadonlyArray<Bear> =

    [], //The default arg is the initial state action: BearAction //This is why we have defined the BearAction type ): ReadonlyArray<Bear> { //A switch is frequent, but not mandatory switch (action.type) { case ADD_BEAR: return [...state, { name: action.name }]; case CLEAR_BEARS: return []; default: //You must return the current state by default return state; } } 25
  20. The principle of state immutability « Redux expects that all

    state updates are done immutably » Never perform - in plain Redux and plain TS - something like this within a reducer: state.myField = 90; Instead, to change one or more fields, you must create a new object holding the state - for example, via the spread syntax State immutability provides a wide range of benefits, such as: code safety: no unexpected state modifications performances: Redux is based on shallow equality checking 26
  21. Errors in reducers « By default, errors thrown within a

    reducer bubble up the call stack, thus terminating the computation induced by dispatch() » Should reducers contain try/catch ? Most probably no, because: syntax errors - especially in vanilla JS/ES - should emerge as quickly as possible domain errors should be expressed via dedicated state fields in lieu of throwing Finally, the default behaviour can be arbitrarily customized by introducing middleware - a topic we are going to discuss in the next section 27
  22. Combining reducers The store demands exactly one reducer - named

    root reducer; however, dealing with a huge, monolithic state within a single function would be rather inconvenient « You split the root reducer into multiple, independent sub- reducers - each operating on different parts of the state object » This can be achieved in many ways - and mainly via the combineReducers() function: const rootReducer = combineReducers({ bears: bearReducer, rangers: rangerReducer }); 29
  23. Understanding reducer combination Whenever a compound reducer is called, the

    following logical steps occur: 1. it receives the current state and the dispatched action 2. it creates a new object having the same fields as the object passed to combineReducers() : i. each sub-reducer is called with the value of the related field in the compound state - but also with the action passed to the compound reducer ii. the result of each sub-reducer is assigned to its related field in the new object 3. The return value of the compound reducer will be: the new object - if at least one sub-reducer returns a different reference the current state otherwise 30
  24. One action, potentially multiple effects In the previous example: bearReducer()

    computes the value for the bears field of the state rangerReducer() computes the value for the rangers field of the state Despite this output differentiation, each reducer receives every single dispatched action; consequently: « Different reducers can react to the same action type » For example, different parts of the domain model could react differently to the same global event 31
  25. Where should the business logic reside? In action creators: +:

    traditionally easier approach for beginners -: actions become thick objects with duplicated business logic -: the effects of action creators are not affected by time-travel debugging In reducers (recommended): +: single source of truth for computations +: pure functions foster code robustness 32
  26. Setters and events In Redux, one dispatches actions - that

    are, technically speaking, messages. In semantic terms, however, messages can be interpreted as: setters - like bears/add - that describe commands to be executed by the system events - like bears/added - which denote something that already happened and that might trigger business logic within the system Redux is technically unopinionated, and different architectural styles might have different visions - not to mention a hybrid approach: what matters is to be aware of the semantic distinction 33
  27. Selectors « Selectors are pure functions that know how to

    extract specific pieces of information from the store state » function getBearNames(state: ReadonlyArray<Bear>): ReadonlyArray<string> { return state.bears.map(bear => bear.name); } Selectors are essential in terms of minimalist design, as they: prevent duplicated domain logic in different parts of the system can express calculated properties, thus preventing redundant data in the store Memoization libraries like reselect can remarkably enhance selector performances 34
  28. Middleware alters the dispatch process « Middleware provides a third-party

    extension point between dispatching an action, and the moment it reaches the reducer » In particular, middleware: exists for the lifetime of the store can see all dispatched actions and dispatch actions by itself can customize the dispatch process 36
  29. Async actions Async actions are action instances that are not

    serializable - and would not be accepted by a reducer: for example, a function, or a Promise . « Async action can be dispatched - but only serializable actions should enter the reducer » More precisely, it is the middleware chain that detects async actions and dispatches actions accordingly 37
  30. Middleware in detail « A middleware is a higher-order function

    that composes a dispatch function to return a new dispatch function » The redux package exports two fundamental type definitions: type MiddlewareAPI = { dispatch: Dispatch; getState: () => State }; represents a minimalist view of the store - with just dispatch() and getState() type Middleware = (api: MiddlewareAPI) => (next: Dispatch) => Dispatch; Middleware needs to be plugged into the store: by design, you can only do so when creating the store - by passing the list of middlewares to applyMiddleware() 38
  31. Applying middleware One of the first problems with Redux is

    logging: in particular, store subscribers can only access the store state, not the actions. The solution is to apply middleware when creating the store - e.g. via redux-logger : import { createStore, applyMiddleware } from "redux" import { createLogger } from "redux-logger" const logger = createLogger({...}) //One can pass a variety of options const reducer = ... export const createCustomStore = () => createStore( reducer, applyMiddleware(logger) ) 39
  32. Middleware in the ecosystem Advanced reducers: reduce-reducers, redux-ignore, reduxr-scoped-reducer Sophisticated

    listening: redux-watch, redux-subscribe, ... Asynchronous actions: Redux Thunk, Redux-Saga, ... Action batching: redux-batched-actions, redux-batch Subscription: redux-batched-subscribe Logging: redux-logger, redux-log-slow-reducers Debugging: Redux DevTools 41
  33. Creating middleware Creating one's custom middleware via a factory method

    is actually fairly straightforward - especially when using curried notation for lambdas: import { MiddlewareAPI, Dispatch, Action, Middleware } from "redux"; export function createCustomMiddleware< TAction extends Action >(/*Custom params used to create the middleware*/): Middleware { //This series of chained lambdas represents the middleware return (store: MiddlewareAPI) => (next: Dispatch<TAction>) => (action: TAction) => { //TODO: Add here the actual code of the middleware //Call next(action) to pass the action to the next mw in the chain. //You can also call store.dispatch() with any new action. }; } 42
  34. Effective minimalism « Redux Toolkit makes it easier to write

    good Redux applications and speeds up development, by baking in our recommended best practices, providing good default behaviors, catching mistakes, and allowing you to write simpler code » It is available as a package via NPM: @reduxjs/toolkit 45
  35. Battery-included store Redux Toolkit provides an enhanced store creator -

    configureStore() , taking an options object having the following core fields: reducer : can be either a reducer or the object that would be passed to combineReducers() preloadedState : optional initial state devTools - enables/disables/configures Redux DevTools in development only: if true (the default), Redux DevTools will have a predefined configuration if a DevToolsOptions instance, it will be used to configure Redux DevTools 47
  36. Middleware in bundle Redux Toolkit's configureStore() automatically applies middleware to

    the new store - especially Redux Thunk; additionally, development and production have different default lists of applied middleware. Middleware can be customized by passing another field to configureStore() 's options - middleware - which can be: an array of middleware - in lieu of calling applyMiddleware() a higher-order function, taking the getDefaultMiddleware() function and returning, especially via the prepend() and concat() methods, an array of middleware instances - if you want to add middleware to the default list 48
  37. Composable and efficient selectors Redux Toolkit exports createSelector() - actually

    provided by the reselect package. To create a selector, you can: just define it as a function (state) => T , with arbitrary T use createSelector() , which takes: an array of source selectors, defined in either way - ensuring composability a function whose arguments will be the actual return values of the sources and that must compute the selector's value This kind of compound selectors relies on memoization for efficiency: the value returned by a compound selector stays the same as long as so do the sources 49
  38. Simplified action creators createAction<TPayload>(type: string, [prepare]) returns an action creator

    with: toString() : the action type - so, there is no more need for separate string constants match(action) : true if the action type matches - very useful in type guards Furthermore, this action creator returns a PayloadAction<TPayload> - an interface extending Action and having the additional payload field - standardizing action payloads all over Redux Toolkit 51
  39. Generating a custom payload The prepare argument in createAction() is

    optional: if prepare is omitted, the action creator takes just one argument - defined by the TPayload type argument of createAction() if the type argument is omitted as well, it defaults to undefined - and the action creator will take no argument otherwise, prepare must be a function taking any argument list and returning an object of type {payload: TPayload} - where TPayload is arbitrary The actual creator will automatically add the type field 52
  40. Simplified and flexible reducers createReducer(initialState, (builder) => {}) simplifies the

    implementation of a reducer via a fluent builder in lieu of the traditional switch construct. A few aspects are definitely worth noting: the very action creators produced by createAction() should be the case argument of builder.addCase(case, (state[, action]) => state) : this ensures type checking for each function passed as the second argument Each subreducer can modify the state! Because createReducer() internally uses the immer library to actually manipulate a proxy wrapped around the state a default case can be specified via the builder, but it is not mandatory - the current state is returned by default 54
  41. Creating a slice createSlice() is the simplest and most elegant

    way to create both the actions and the reducer for a branch of the state - replacing also createAction() and createReducer() : const bearSlice = createSlice({ name: "bears", //The prefix for all the related action types initialState: [{name: "Yogi"}], reducers: { //This creates both the action creator and the related reducer case bearAdded(state: Bear[], action: PayloadAction<string>) { //The action is optional state.push({name: action.payload}) //Fake mutability via Immer } bearsCleared(state: Bear[]) { state.length = 0 } } }) 57
  42. Exporting from a slice Once you have created a slice

    within a module, you can export both the action creators and the overall reducer: export const { addBear, clearBears } = bearSlice.actions; export const bearReducer = bearSlice.reducer; The action creators can be imported and called as usual The reducer takes into account the cases declared within the slice and can in turn become a sub-reducer passed to a combining function such as combineReducers() 58
  43. Decoupled action creator and reducer in a slice You can

    actually decouple the action creator and the reducer in a slice as well: type AddBearPayload = {name: string, token: number} (...) reducers: { addBear: { //This works just like prepare in createAction() prepare: (name: string) => { //As usual, you definitely mustn't specify the "type" field here return { payload: { name, token: DateTime.now() } } }, //This works just like a case in createReducer() //Static typing ensures that action is PayloadAction<BearCreatedPayload> reducer: (state, action) => { state.push({name: action.name}) } } 59
  44. Extra reducers In slices, each reducer acts like a switch

    case applied to its specific action type, because of the coupling with action creators provided by createSlice() . However, a slice might need to logically handle actions whose creators are not defined in the slice itself. The options passed to createSlice() can have another, optional field, extraReducers : its value must be a lambda taking just a builder which works like in createReducer() . Ultimately, both reducers and extraReducers are combined into the slice's reducer 60
  45. Is Redux synchronous? By design, Redux is synchronous: you cannot

    add asynchronous logic to reducers, as they must be pure functions you could add async logic to action creators, but that is not recommended both the state and the action received by the reducer must be serializable - so promises and functions cannot be dispatched However, Redux Thunk - that configureStore() applies by default - alters the dispatch process to support asynchronous operations by dispatching thunks 63
  46. Thunks « A thunk is a function that wraps an

    expression to delay its evaluation, such as () => 42 » In the case of Redux Thunk, a thunk is a higher-order function receiving two arguments, actually referencing the store methods: dispatch() getState() You usually return thunks from thunk creators - functions whose arguments customize the thunk behavior 64
  47. Dispatching thunks Dispatching a thunk runs its code, which can

    include: reading the current state of the store dispatching actions or even other thunks performing one or more await , if the thunk is an async function The body of the thunk is arbitrary - and it is executed as soon as it is dispatched 65
  48. Typical thunk workflow Most often, async operations follow a dedicated

    pattern: 1. Dispatch an action notifying that the operation is pending: this usually alters the state so that the application's view displays some Waiting... component 2. Perform arbitrary operations, that might also involve await on promises 3. Finally, dispatch an action notifying: The success of the operations - with the related payload, which is usually the result of the computation process The failure of the operations - maybe with the related error In both cases, the UI is updated accordingly 66
  49. Simplified thunk creation When TypeScript's static type checking is enforced,

    even the implementation of a basic pattern for asynchronous operations via Redux Thunk can become exponentially cumbersome. Consequently, Redux Toolkit exports the createAsyncThunk() function to define a thunk creator, which in turn creates a thunk that: executes an arbitrary block of code - the actual logic, encapsulated within a Promise keeps track of the async action lifecycle by dispatching the related actions - pending , fulfilled and rejected - all sharing the same prefix 67
  50. Using createAsyncThunk() createAsyncThunk() , in a fairly basic form, takes

    the following arguments: the type prefix shared by the 3 workflow actions a factory function - whose optional arguments are: arg : the argument that could be passed to the thunk creator thunkAPI - object with essential APIs such as dispatch() and getState() The thunk creator returned by the function has 3 properties - pending , fulfilled and rejected - that reference the action creators, as returned by createAction() , of the lifecycle actions - so they can be used in reducer cases 68
  51. The return value of dispatch() The dispatch() method provided by

    the store dispatches actions to the middleware chain and ultimately to the reducer - but its return value has interesting properties: by default, dispatch() returns the dispatched action however, if middleware is applied to the store, dispatch() returns the value returned by the middleware in particular, when using Redux Thunk combined with createAsyncThunk() , dispatch() returns the Promise returned by the thunk - consequently, one can use await on its return value, within an async function 70
  52. One-way data flow One-way data flow is a paradigm adopted

    by both React and Redux: in Redux, via the immutability of the current state, plus the dispatch() mechanism in React, information flows via properties from a component to its sub-components React's waterfall approach actually works - but it has a major drawback: if a node holds data that are required in a remote descendant, such data must be passed to all the intermediary node as well. This is an inelegant but fairly infrequent scenario, although there may be more compelling reasons to adopt Redux, so: « Don't use Redux until you have problems with vanilla React » 72
  53. Introducing React Redux « React Redux is the official Redux

    UI binding library for React » The very essence of React Redux is: subscribing to the store created by Redux updating the UI: only when needed - that is, if the state actually changes only where needed - to minimize DOM modifications 73
  54. Where should the state reside? When Redux is added to

    a React app, there are at least 3 possible locations where each piece of the app state can reside: global state - provided by the Redux store: it can be modified only by dispatching actions to the store data can be extracted from it via selectors As the name implies, it is best suited for app-wide global information component state - living within the single React component and provided, for example, by the useState() hook. Typically recommended for form controls context state - an intermediate state container provided by React 74
  55. Components: connected or presentational? « When a React component interacts

    with the Redux store, it becomes a connected component » Not all components need to be connected: on the contrary, most React components should be presentational - that is, relying just on their properties and maybe their internal state. Consequently, in React Redux it is common to see connected components that fetch data from the Redux store and propagate such data to their sub-tree of presentational components 75
  56. Plugging the Redux store into React React Redux is available

    via NPM as react-redux . After the installation, it takes just a few steps: 1. Create the store, as you would in vanilla Redux - for example, you can export either the store instance or a store creator from a dedicated module 2. In JSX, plug the Provider tag - usually as the parent of the application's root component tag passed to React's render() function - assigning the store instance to its store property: <Provider store={myStore}> <MyReactApp /> </Provider> 76
  57. Connecting components to the store To create a connected component:

    if the component is implemented as a class - which is less and less frequent, the connect() function is required - as well as the related boilerplate code in modern, functional React components, everything can be achieved via hooks: useSelector(selector) : returns the value it calculates from the current state within the store useDispatch() returns a reference to the store's dispatch() method 77
  58. Performance optimizations Calling useSelector() many times with different selectors should

    be preferred over calling useSelector() once with an aggregating selector real performance gains can be achieved via createSelector() - which is provided by reselect as well as Redux Toolkit General advice also apply - for example, in reducers it is more efficient to return the very same state instance rather than a deep copy 78
  59. Global takeaways Do you really need Redux? Just like any

    other software component, you should adopt it in scenarios where the benefits outweigh the drawbacks Use createSlice() to create the action creators and the reducers for each slice of the domain: this will make you especially rely on PayloadAction<T> Call configureStore() to create the store with default middleware and DevTools Invoke the store methods - especially dispatch() and getState() Rely on createAsyncThunk() for asynchronous operations Call useSelector() and useDispatch() in React Redux for elegant code 80
  60. Redux projects - directory structure Redux is not opinionated in

    terms of directory layout, thus different choices are viable: group by feature: the recommended style, based on Domain-Driven Design. It seems wise to have a bears.ts module containing everything about bears: action type constants and action interfaces action creators (exported) reducer (exported) group by construct: à la Ruby on Rails - with one folder per construct ( actions/ , reducers/ , ...), each containing a module per feature ( bears.ts , rangers.ts , ...) 81
  61. Further references Redux - Official website Redux Toolkit Redux Thunk

    Redux Saga - a generator-based approach to async operations React Redux Redux - Style guide Middleware evolution - step-by-step architectural explanation React Redux sample app Elm - «A delightful language for reliable webapps» 82