Graduate Program KB

What is the Store?

  • An application is composed primarily of UI and State

    • Often, a common cause for bugs is state mismanagement
    • Increasing the predictability of state can decrease the amount of bugs
  • A state tree is a single location which stores all our state in an application

    • Increases the predictability of state changes, define clear rules for how to access and update that state
    • State becomes global, it can be shared to other components regardless of their location
    • Improved developer tooling, other parts of the application can be reloaded without throwing away our state
    • Improves server-side rendering, easier to send state and return markup as a response
    • Relating to the idea of pure functions, by having all our state in one location, the rest of the application is simply receiving state and displaying UI based on that state
  • The Store consists of the state tree and a way to retrieve it, updatee it and listen for changes

    • To create a store, call configureStore({ reducer: rootReducer }) where rootReducer is a reducer function. Can also use createStore

      • rootReducer takes in two parameters, the current state and an action event to handle
      • It will return a new state as a result of the action event specified
      function postsReducer(state = [], action) {
          if (action.type === "UPVOTE") {
              return ...  // New updated state
          }
          else if { ... }
          else { return state; }
      }
      
      • We can also have multiple reducers to handle different types of tasks, the reducer property becomes an object of different reducers
      const store = configureStore({
          reducer: {
              posts: postsReducer,
              friends: friendsReducer
          }
      })
      
      • The state management object will contain different types of state as a result that will be updated through the rootReducer
      • The object of reducers will automatically combine to create the rootReducer in Redux through the combineReducers utility
      function rootReducer(state = {}, action) {
          return {
              posts: postsReducer(state.posts, action),
              friends: friendsReducer(state.friends, action)
          };
      }
      
    • Calling getState will return the current state tree

      • State can be updated by using dispatch(action)
      • State transformation events are represented as actions, an object which stores the type of action, and any additional information needed to undertake that action
      const action = {
          type: "UPVOTE",
          id: 950788310443724800,
          uid: "Bob",
      };
      
      • Calling dispatch(action) invokes the reducer function passed to the store with the current state and action
      • Once the reducer returns the updated state, every attached listener will be notified of this change
    • subscribe(listener) allows us to listen for state changes

      • It takes in a listener callback function then our callback will be added to an array of listeners
      • A cleanup function is returned and invoked whenever the listener needs to be unsubscribed

User Interface

  • Using JavaScript and HTML

    <!DOCTYPE html>
    <html>
    <head>
        <title>TODOs</title>
    </head>
    <body>
        <div>
            // UI
        </div>
        <script type="text/javascript">
            // JavaScript code
        </script>
    </body>
    </html>
    
  • Can add/remove UI elements when updating the store, the example below adds a <li> element when a new TODO item is added

    <div>
        <h1>Todo List</h1>
        <input id='todo' type='text' placeholder="Add Todo">
        <button id='todoBtn'>Add Todo</button>
        <ul id='todosUL'></ul>
    </div>
    <script type="text/javascript">
        // Library store code
        // Action functions
        // TODO Reducer
    
        const store = createStore(todoReducer);     // Use combineReducers({ ... }) if there are multiple reducers
    
        store.subscribe(() => {
            const { todos } = store.getState();
            
            document.getElementById('todosUL').innerHTML = '';
            todos.forEach(addTodoToDOM);    // Whenever a new TODO is added, add a list element to the DOM to display it
        });
    
        // DOM code
    
        function addTodo () {
            const newId = ...   // Some way to generate a unique ID
            const input = document.getElementById('todo');
            const name = input.value;
            input.value = '';
    
            store.dispatch(addTodoAction({
                id: newId,
                name,
                complete: false,
            }));
        }
    
        function addTodoToDOM(todo) {
            const node = document.createElement('li');                              // Display the TODO item and create a button to remove it
            const text = document.createTextNode(todo.name);
    
            const removeBtn = document.createElement('button');
            removeBtn.innerHTML = 'X';
            removeBtn.addEventListener('click', () => {
                store.dispatch(removeTodoAction(todo.id));
            });
    
            node.appendChild(text);
            node.appendChild(removeBtn);
    
            node.style.textDecoration = todo.complete ? 'line-through' : 'none';    // Add strikethrough text styling if TODO is completed
            node.addEventListener('click', () => {                                  // If the TODO is clicked, toggle its completion status
                store.dispatch(toggleTodoAction(todo.id));
            });
    
            document.getElementById('todosUL').appendChild(node);                   // Add the new list element to the unordered list element
        }
    
        document.getElementById('todoBtn').addEventListener('click', addTodo);
    </script>
    

Middleware

  • If you want to customise the dispatch to do some preliminary processing, you can create an intermediary function

    • With Redux, middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer
    function middleware(store) {
        return function(next) {
            return function(action) {
                // Preliminary action, data preprocessing, etc.
                ...
    
                const result = next(action);
    
                // Perform task on result, logging, etc.
                ...
    
                return result;    // Pass to reducer
            }
        }
    }
    
    const middleware = (store) => (next) => (action) => {       // ES6 syntax
        // Preliminary action, data preprocessing, etc.
        ...
    
        const result = next(action);
    
        // Perform task on result, logging, etc.
        ...
    
        return result;    // Pass to reducer
    }
    
    const store = createStore(todoReducer, applyMiddleware(middleware));    // applyMiddleware is a Redux function
                                                                            // It takes in an arbitrary amount of middleware functions
    
  • Some popular packages in the Redux ecosystem implemented via middleware

    • redux-api-middleware: Redux middleware for calling an API
    • redux-logger: Logger middleware for Redux
    • redux-promise-middleware: Redux middleware for resolving and rejecting promises with conditional optimistic updates
    • redux-thunk: Thunk middleware for Redux
    • redux-logic: Redux middleware for organizing business logic and action side effects
    • redux-observable: RxJS middleware for action side effects in Redux using "Epics"
    • redux-test-recorder: Redux middleware to automatically generate tests for reducers through ui interaction
    • redux-reporter: Report actions & metadata to 3rd party providers, extremely useful for analytics and error handling (New Relic, Sentry, Adobe DTM, Keen, etc.)
    • redux-localstorage: Store enhancer that syncs (a subset) of your Redux store state to localStorage
    • redux-node-logger: A Redux Logger for Node Environments
    • redux-catch: Error catcher middleware
    • redux-cookies-middleware: A Redux middleware which syncs a subset of your Redux store state with cookies
    • redux-test-recorder: Redux test recorder is a Redux middleware + included component for automagically generating tests for your reducers based on the actions in your app

Redux with React

  • Instead of regular JavaScript and HTML, Redux can be used with React as well
  • For components in an application to gain access to the store, it can be passed within the component tree through props
  • Use lifecycle methods to subscribe to listeners
    componentDidMount() {
        const { store } = this.props;
    
        store.subscribe(() => this.forceUpdate());  // Forces a re-render just like this.setState, typically not used because it's an anti-pattern
    }
    

Async Redux

  • Side effects such as using APIs to fetch the initial data can be done before subscribing and forcing a re-render

    componentDidMount() {
        const { store } = this.props;
    
        Promise.all([API.fetchTodos()])     // If you want to render initial TODO data from some API
        .then(([todos]) => {
            store.dispatch(someActionFunctionToStoreReceivedData(todos));   // Add TODOs to the state managing its list
        });
    
        store.subscribe(() => this.forceUpdate());  // Forces a re-render just like this.setState, typically not used because it's an anti-pattern
    }
    
    • Can also create a reducer called loading for example, to determine if the UI should display yet if the data is not fetched
    function loading(state = true, action) {
        switch(action.type) {
            case RECEIVE_DATA :
                return false;
            default :
                return state;
        }
    }
    
    ...
    
    render() {
        const { todos, loading } = this.props.store.getState();
    
        if(loading === true) {
            return <h3>Loading</h3>
        }
    
        return ...
    }
    
  • A loading slice can be added to the store, which provides an interface to check if there is any awaiting asynchronous data

    • Can create another reducer that checks if an action is asynchronous. If it's asynchronous, return false, otherwise, return true
    • This is a form of implementing conditional rendering using Redux
  • The UI can be rendered optimistically, which means it displays anticipated changes without waiting for confirmation from the server

    • In the case of errors, a rollback will occur to display the previous state
    • We can separate the asynchronous logic from the component logic through encapsulation using thunks
    • The thunkified action creators return a function with dispatch as a parameter
    • The following example has an event handler to delete items before adding it back if our API request fails
    function handleRemoveTodo(todo) {   // Separated asynchronous logic into its own function
        return (dispatch) => {
            dispatch(removeTodoAction(todo.id));
    
            return API.deleteTodo(todo.id).catch(() => {
                dispatch(addTodoAction(todo));
                alert('An error occurred. Try again.');
            });
        }
    }
    
    const thunk = (store) => (next) => (action) {   // Middleware, passing in store's dispatch function to allow action creators to update the store
        if (typeof action === "function") {         // Remember to add to applyMiddleware
            return action(store.dispatch);
        }
    
        return next(action);
    }
    
    // React
    const handleRemoveItem = (todo) => ({ this.props.store.dispatch(handleRemoveTodo(todo)) });     // No more asynchronous logic
    
    • Redux has its own thunk middleware
    const store = createStore( ... , applyMiddleware(ReduxThunk.default, ... ));
    
  • There are usually three stages of an asynchronous request. First is the start of the request, second is if the request succeeds, and third is if the request fails

    • The best UIs account for and inform the user about each stage
    const initialState = {
        data: [],
        isFetching: false,
        error: "",
    };
    
  • Other middleware for asynchronous options

    • Redux Promise: FSA-compliant promise middleware for Redux
    • Redux Saga: An alternative side effect model for Redux apps

react-redux

  • The store is being passed around to components through props which is unrealistic in larger applications

  • Whole application is re-rendered whenever a force update occurs from the subscribe listener

  • The following example extracts some of these issues

    • Context provides a way to pass data through a component tree without having to pass props manually
      • Use createContext for each unique piece of data that needs to be available
      • Provider allows us to declare the data we want available
      • Consumer allows any component in the tree that needs the data to be able to subscribe to it
    const Context = React.createContext();
    
    class Provider extends React.Component {
        render() {
            return (
                <Context.Provider value={this.props.store}>
                    {this.props.children}
                </Context.Provider>
            )
        }
    }
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
        <Provider store={store}>
            <App />
        </Provider>
    );
    
    class TODO extends React.Component {
        ...
    
        render() {
            return (
                <Context.Consumer>
                    {(store) => {
                        // Any logic that needs the store
                        ...
                    }}
                </Context.Consumer>
            )
        }
    }
    
    • However, componentDidMount uses the store but can't access it from the render method scope
    • Create a connected component to work around this problem
    // Provider class
    ...
    
    class ConnectedApp extends React.Component {
        render() {
            return (
                <Context.Consumer>
                    {(store) => (
                        <App store={store} />
                    )}
                </Context.Consumer>
            )
        }
    }
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
        <Provider store={store}>
            <ConnectedApp />
        </Provider>
    );
    
    • Similar to ConnectedApp, for all child components that need store, create another class that consumes the context and put any necessary logic within that scope
    class ConnectedTODO extends React.Component {
        render() {
            return (
                <Context.Consumer>
                    {(store) => {
                        const { todos } = store.getState()
    
                        return <TODO todos={todos} dispatch={store.dispatch} />
                    }}
                </Context.Consumer>
            )
        }
    }
    
    class App extends React.Component {
        ...
    
        render() {
            return (
                <div>
                    <ConnectedTODO />
                </div>
            )
        }
    }
    
  • We can refactor the code before by creating another layer of abstraction, we want to handle all the context sharing under the hood

    • Create a library to implement a function that returns a Component that needs to be rendered
    const ConnectedApp = connect((state) => ({
        loading: state.loading
    }))(App);
    
    // Library code
    
    const Context = React.createContext();
    
    function connect(mapStateToProps) {
        return (Component) => {
            class Receiver extends React.Component {
                componentDidMount() {
                    const { subscribe } = this.props.store;
    
                    this.unsubscribe = subscribe(() => this.forceUpdate());
                }
    
                componentWillUnmount() {
                    this.unsubscribe();
                }
    
                render() {
                    const { dispatch, getState } = store;
                    const state = getState();
                    const stateNeeded = mapStateToProps(state);
    
                    return <Component {...stateNeeded} dispatch={dispatch} />
                }
            }
    
            class ConnectedComponent extends React.Component {
                render() {
                    return (
                        <Context.Consumer>
                            {(store) => <Receiver store={store} />}
                        </Context.Consumer>
                    )
                }
            }
    
            return ConnectedComponent;
        }
    }
    
  • The recommended way is to use the react-redux library

    • Don't need the library code from before, the createContext invocation or the Provider class anymore
    • Instead of creating our own context, use the in-built context from react-redux
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
        <ReactRedux.Provider store={store}>
            <ConnectedApp />
        </ReactRedux.Provider>
    )
    
    • Anywhere that used the connect function we created before, can use ReactRedux.connect instead
    const ConnectedApp = ReactRedux.connect((state) => ({
        loading: state.loading
    }))(App);
    
    const ConnectedTODO = ReactRedux.connect((state) => ({
        todos: state.todos
    }))(TODO);
    

Folder Structure

  • Create separate folders for actions, reducers, middleware and components

    my-react-project
        public
        src
            actions
                ...
            reducers
                ...
            middleware
                ...
            components
                ...
            index.js
        .gitignore
        package-lock.json
        package.json
        README.md
    
    • Within those folders, create separate files for functions relating to handling specific components
    • Can create an index.js file for the reducers and middleware folder if applicable
      • Import the reducers/middleware from the other files
      • Use combineReducers or applyMiddleware and export it
    • To create the store, use the main index.js file
    import * as React from "react";
    import * as ReactDOM from "react-dom";
    import App from "./components/App";
    import reducer from "./reducers";
    import middleware from "./middleware";
    import { Provider } from "react-redux";
    import { createStore } from "redux";
    
    const store = createStore(reducer, middleware);
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
        <Provider store={store}>
            <ConnectedApp />
        </Provider>
    );
    
  • Other folder structures

    • Ducks - Redux Reducer Bundles

      • Have a module file which represents a single reducer along with any action creators/constants associated with it
    • Domain based folders

      • Group actions, reducers, etc. by the domain they care about
      • Rather than separating them out into generic actions and reducers folders

Hooks

  • The Higher-order component, connect is a convoluted pattern used before functional components and hooks existed

    • Used to have to rely on the connect component to connect a component to the Redux store
    • react-redux v7.1.0 can now break out that logic into separate custom hooks
  • useSelector allows you to extract data from the Redux store using a selector function

    import { useSelector } from "react-redux";
    
    function Profile () {
        const user = useSelector((state) => state.user);
    
        ...
    }
    
    • To get multiple values from the store, use separate calls to take advantage of batching, which causes only a single render to occur
    • Trying to obtain all the values through object destructuring is not as performant, useSelector uses strict reference equality to decide when re-renders occur
    const user = useSelector((state) => state.user);
    const authed = useSelector((state) => state.authed);
    const notifications = useSelector((state) => state.notifications);
    
  • useDispatch returns a reference to the store's dispatch function

    import { useDispatch } from "react-redux";
    
    function Profile () {
        const dispatch = useDispatch();
    
        ...
    }
    
  • Referencing the code examples used so far, here are some code snippets to demonstrate how refactoring would occur

    export default function App() {
        const dispatch = useDispatch();
        const loading = useSelector((state) => state.loading);
    
        React.useEffect(() => {
            dispatch(handleInitialData());  // Function to fetch initial data from an API
        }, [dispatch]);
    
        return ...
    }
    
    export default function TODO() {
        const inputRef = React.useRef(""null"");
        const dispatch = useDispatch();
        const todos = useSelector((state) => state.todos);
    
        const addItem = (e) => {
            e.preventDefault();
    
            dispatch(handleAddTodo(
                inputRef.current.value,
                () => inputRef.current.value = "";
            ));
        };
    
        const toggleItem = (id) => { dispatch(handleToggle(id)); }
    
        const removeItem = (todo) => { distpatch(handleRemoveTodo(todo)); };
    
        return (
            <div>
                <h1>Todo List</h1>
                <input
                    type="text"
                    placeholder="Add TODO"
                    ref={inputRef}
                />
                <button onClick={addItem}>Add TODO</button>
                <List
                    toggle={toggleItem}
                    items={todos}
                    remove={removeItem}
                />
            </div>
        );
    }