Graduate Program KB

Redux Notes


What is the Store?

  • The two most fundamental component of an app are the UI and state

  • Bugs typically occur due to state mis-management

    • Increasing predictability of state decreases the number of bugs
  • Instead of sprinkling state all over the application, it should be stored in a single location, this is known as the 'state tree'

  • Benefits of a state tree:

    • Shared cache, any component which needs access can get access regardless of location in the application
    • Predictable state changes
    • Improved developer tooling
    • Pure functions
    • Server rendering
  • Now we need a way to interact with out state (get it, update, listen etc.). This is called the store, it consists of the state tree and the ways in which we will interact with it

An example of a store is shown below. The method getState and subscribe are returned so that a user can use them. Obviously this is a bare-bones implementation.

function createStore() {
  let state
  let listeners = []

  const getState = () => state

  const subscribe = (listener) => {
    listeners.push(listener)
    return () => {
      listeners = listeners.filter((l) => l !== listener)
    }
  }

  return {
    getState,
    subscribe,
  }
}

Representing State Transformations as Actions

  • For increased predictability, we want to create a collection of all possible events that occur in our application which will update the state
  • We will use Javascript objects for this, but re-brand them as 'actions'
    • Actions are payloads of info which send instruction on what type of transformation to apply to your applications state, along with any other relevant information

Example of a typical action:

const action = {
  type: 'LIKE_TWEET',
  id: 950788310443724800,
  uid: 'tylermcginnis',
}

Note that actions must have a type property to specify the type of action occurring. The other properties are supporting info, this is up to us to decide what is relevant.

Another example for a Todo/Goal application:

{
    type: "ADD_TODO",
    todo: {
        id: 0,
        name: "Learn Redux",
        complete: false,
    }
}

Reducer Composition

  • The idea is to create a reducer to manage each section of your store, and any nested data Using a state tree with this example shape:
{
  "users": {},
  "setting": {},
  "tweets": {
    "btyxlj": {
      "id": "btyxlj",
      "text": "What is a jQuery?",
      "author": {
        "name": "Tyler Mcginnis",
        "id": "tylermcginnis",
        "avatar": "twt.com/tm.png"
      }
    }
  }
}
  • The three main properties are users, settings, and tweets
  • Instead of creating individual reducers for each, we can create one single root reducer
const reducer = combineReducers({
  users,
  settings,
  tweets,
})
  • combineReducers is responsible for invoking all other reducers, and passing them the portion of state they care about

  • Here is a more fleshed-out version of a store in redux:

// One of each of these functions for actions we wish to use
function addTodoAction(todo) {
  return {
    type: 'ADD_TODO',
    todo,
  }
}

// todo reducer
function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return state.concat([action.todo])
    case 'REMOVE_TODO':
      return state.filter((todo) => todo.id !== action.id)
    case 'TOGGLE_TODO':
      return state.map((todo) =>
        todo.id !== action.id
          ? todo
          : Object.assign({}, todo, { complete: !todo.complete }),
      )
    default:
      return state
  }
}

// goals reducer
function goals(state = [], action) {
  switch (action.type) {
    case 'ADD_GOAL':
      return state.concat([action.goal])
    case 'REMOVE_GOAL':
      return state.filter((goal) => goal.id !== action.id)
    default:
      return state
  }
}

// combine reducers
function app(state = [], action) {
  return {
    todos: todos(state.todos, action),
    goals: goals(state.goals, action),
  }
}

function createStore(reducer) {
  let state
  let listeners = []

  const getState = () => state

  const subscribe = (listener) => {
    listeners.push(listener)
    return () => {
      listeners = listeners.filter((l) => l !== listener)
    }
  }

  const dispatch = (action) => {
    state = reducer(state, action)
    listeners.forEach((listener) => listener())
  }

  return {
    getState,
    subscribe,
    dispatch,
  }
}

User Interface

  • Utilising Javascript and HTML
<!doctype html>
<html>
  <head>
    <title>TODOs</title>
  </head>
  <body>
    <div>// UI of input, button and ul/li</div>
    <script type="text/javascript">
      // Previous example goes here
    </script>
  </body>
</html>
  • To interact with the DOM we add some functions within our script tag:
// Within <script>

// Store here (and creation)

store.subscribe(() => {
  const { todos } = store.getState()
  document.getElementById('todos').innerHTML = ''
  todos.forEach(addTodoToDom)
})

function addTodo() {
  const input = document.getElementById('todo')
  const name = input.value
  input.value = ''

  store.dispatch(
    addTodoAction({
      id: generateId(), // function to generate unique id
      name,
      complete: false,
    }),
  )
}

function addTodoToDom() {
  const node = document.createElement('li')
  const text = document.createTextElement(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'
  node.addEventListener('click', () => {
    store.dispatch(toggleTodoAction(todo.id))
  })

  document.getElementById('todos').appendChild(node)
}

document.getElementById('todosBtn').addEventListener('click', addTodo)

Middleware

  • We can customise the dispatch to do some prior processing
  • With Redux, middleware provides a third-part extension point between dispatching an action and the moment it reaches the reducer
const middleware = (store) => (next) => (action) => {
  // Preliminary action etc. (Maybe check for word in todo)
  const result = next(action)
  // Perform task on the result (logging for example)
  return result // To be passed to reducer
}
const store = Redux.createStore(
  Redux.combineReducers({
    todos,
    goals,
  }),
  Redux.applyMiddleware(checker),
)

Popular packages implemented via middleware:

  • redux-api-middleware: Redux middleware for calling an api
  • redux-logger: Logger middleware for redux
  • redux-promise-middleware: For resolving and rejecting promises with conditional optimistic updates
  • redux-thunk: Thunk middleware for redux
  • redux-logic: For organising 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

  • Redux can be used with React, just like with HTML and vanilla Javascript
  • To be able to gain access, the store can be passed through the component tree through props (and accessed similarly)
  • We use lifecycle methods to subscribe to listeners (forcing a re-render for initial to ensure state is set properly. This is an antipattern however)
// Within main App.jsx
componentDidMount() {
  const { store } = this.props
  store.subscribe(() => this.forceUpdate()); // Forces a re-render (like setState)
}

Async Redux

Side effects (like using API's to fetch initial data), can be done before subscribing and forcing a re-render

componentDidMount() {
  const { store } = this.props;

  Promise.all([API.fetchTodos(), API.fetchGoals()])
  .then(([todos, goals]) => {
    store.dispatch(storeDataInStore(todos, goals)); // Some action function to add the state taken from the api to the store
  })

  store.subscribe(() => this.forceUpdate()); // Forces a re-render
}

A reducer, loading, can be used if we want to display something to the user whilst the data is being 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 // ...
}

Optimistic Rendering

  • When we perform an action asynchronously we don't want our user to have to wait to see changes
  • We can perform optimistic rendering, e.g. for a delete action:
    • We delete the todo from the store (synchronous), then attempt to delete it from our database
    • If the database deletion was not successful, we add the todo back to our local store
    • From the users perspective the deletion occurs synchronously, unless there was an error deleting from the database
function handleRemoveTodo(todo) {
  return (dispatch) => {
    dispatch(removeTodoAction(todo.id))
    return API.deleteTodo(todo.id).catch(() => {
      dispatch(addTodoAction(todo))
      alert('An error occurred. Try again.')
    })
  }
}

Thunks

3 Stages to an asynchronous request:

  1. Start of the request
  2. If the request succeeds
  3. If the request fails

The best UI's account for and inform the user about each stage. Our state should also account for each stage. Based on this, we could expect our initial state to look something like this:

const initialState = {
  data: [],
  isFetching: false,
  error: '',
}
  • Our corresponding reducer could account for the 3 stages, and update the state accordingly.

  • To be able to have an action creator which fetches and gives data to other action creators, we need to have access to dispatch in the action creator itself somehow.

const thunk = (store) => (next) => (action) => {
  if (typeof action === 'function') {
    return action(store.dispatch)
  }
  return next(action)
}

More Asynchronous Options

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

React-Redux

  • Currently, the store is being passed to components through props which is unrealistic in larger applications

  • This causes the whole application to br re-rendered whenever the force update occurs from the subscribed listener

  • We can use context to pass data through the tree, without having to pass them as props manually

  • createContext should be used for each unique piece of data which 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) => {
          // Anything which requires the store
        }}
      </Context.Consumer>
    )
  }
}
  • componentDidMount also needs the store, but can't access it from the render scope
  • A connected component can be used as a work around
// 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>,
)

For any child components that also require store, we can create another class which consumes the context, and then we can put any necessary logic in 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 {
  // ...
  return (
    <div>
      <ConnectedTODO />
    </div>
  )
}
  • At another layer of abstraction, we may want to handle all the context sharing under the hood
    • We could create a library to implement a function that returns a Component that needs to be rendered
    • It is better to use the react-redux library instead

We not longer need the createContext invocation or the Provider class any more. We will use built-in context from react-redux.

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <ReactRedux.Provider store={store}>
    <ConnectedApp />
  </ReactRedux.Provider>,
)
// Use ReactRedux.connect to handle context sharing?

const ConnectedApp = ReactRedux.connect((state) => ({
  loading: state.loading,
}))(App)

const ConnectedTODO = ReactRedux.connect((state) => ({
  todos: state.todos,
}))(TODO)

Folder Structure

  • We should have separate folders for actions, reducers, middleware and components. These folders will exist with /src
  • Within these folders, there will be separate files for different functions which handle specific components
    • If applicable, create an index.js file for the reducers and middleware folder
    • Middleware/reducers will be imported from the other files
    • combineReducers/applyMiddleware will be used then exported
  • The store itself should be created in the main index.js file, and passed to provider

Other Structures

Ducks - Redux Reducer Bundles:

  • Module file which represent a single reducer
  • Relevant action creators/constants
  • Begins to fail with action types associated with multiple reducers

Domain Based Folders:

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

Redux Hooks

  • Before React hooks, convoluted patterns for composing non-visual logic were used
  • react-redux's connect component is an example of this
    • It is a higher order component which allows you to connect a react component to the Redux store
function Profile({ dispatch, user }) {
  // ...
}
export default connect((state) => {
  return {
    user: state.use,
  }
})(Profile)
  • Now with hooks, instead of relying on connect, react-redux has broken up accessing the state and accessing dispatch into separate hooks

useSelector

  • useSelector's purpose is to extract data from the Redux store using a selector function
import { useSelector } from 'react-redux'

function Profile() {
  const user = useSelector((state) => state.user)
  // ...
}
  • The function passed to useSelector will be passed the state from the store, from there you can grab a value from the state by returning it
  • If we want to select multiple thing from the store, we could return an object with those selected states, however this would not be performant as a re-render would be triggered each time due to the strict referential equality check behind the scenes
  • Instead we can break each piece of state into their own selector
const user = useSelector((state) => state.user)
const authed = useSelector((state) => state.authed)
const notifications = useSelector((state) => state.notifications)
  • react-redux will batch these selectors together, therefore only performing a single re-render

useDispatch

  • Returns a reference to the store's dispatch function
import { useDispatch } from 'react-redux'

function Profile() {
  const dispatch = useDispatch()
  // ...
}

Refactoring Using Hooks

export default function App() {
  const dispatch = useDispatch()
  const loading = useSelector((state) => state.loading)

  React.useEffect(() => {
    dispatch(handleInitialData())
  }, [])
  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 => { dispatch(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>
  )
}