Skip to content

Instantly share code, notes, and snippets.

@cjxe
Created October 12, 2023 09:43
Show Gist options
  • Save cjxe/7e850f5f31d2bcf02e11beadadc2a455 to your computer and use it in GitHub Desktop.
Save cjxe/7e850f5f31d2bcf02e11beadadc2a455 to your computer and use it in GitHub Desktop.
Redux fundamentals

Redux fundamentals

Version

  • redux v4.2.1
  • react-redux v8.1.3
  • @reduxjs/toolkit v1.9.7

Sources:

States

What?

Represents the current data stored in the application.

Why?

Redux uses a single centralized state object to manage and store the data for your entire application. This makes it easy to access and modify the data consistently across different parts of your app.

Action

What?

An action is a plain JavaScript object that describes an event or something that has happened in your application. It typically has a type property that indicates the type of action being performed and may also carry additional data (payload) relevant to the action.

Why?

Actions are used to trigger changes to the application's state. When you want to update the state, you dispatch an action. (Then, Reducers handle these actions to determine how the state should change.)

Example in Redux core

Example of an hardcoded action:

const incrementByThree = {type: 'counter/increment', payload: 3}

However, this is not very useful since we have to create the object above every time we need to call it. So, we can turn this into a "reusable action" that takes the payload as an argument:

const increment = (amount: number) => ({
	type: 'counter/increment',
	payload: amount,
});

const action = increment(3)
// { type: 'counter/increment', payload: 3 }

example in TMUI code at src/store/pages/contentManagement/creatives/actions.js

Example in Redux Toolkit

⚠️ Actions are automatically generated by createSlice. However, we can also create an action separately:

import { createAction } from '@reduxjs/toolkit'  
  
const increment = createAction<number | undefined>('counter/increment') 
  
let action = increment()  
// { type: 'counter/increment' }  
  
action = increment(3)  
// returns { type: 'counter/increment', payload: 3 }

Reducer

What?

A reducer is a function that specifies how the application's state should change in response to an action. It takes the current state and an action as arguments and returns a new state object based on those inputs.

Why?

Reducers are at the core of Redux. They define the logic for updating the state in a predictable and immutable way. Reducers are typically pure functions, meaning they don't modify the current state directly but return a new state object.

Example in Redux core

// Use the initialState as a default value  
export default function counterReducer(state = initialState, action) {  
	// The reducer normally looks at the action type field to decide what happens  
	switch (action.type) {  
		// Do something here based on the different types of actions  
		case 'counter/increment': {  
			// We need to return a new state object  
			return {  
				// that has all the existing state data  
				...state,  
				// but has a new number for the `value` field  
				value: state.value + 1 
			}  
		}  
	default:  
		// If this reducer doesn't recognize the action type, or doesn't  
		// care about this specific action, return the existing state unchanged  
		return state  
}  
}

example in TMUI code at src/store/pages/contentManagement/creatives/reducer.js

Example in Redux Toolkit

docs of createReducer()

import { createAction, createReducer } from '@reduxjs/toolkit'  
  
interface CounterState {  
	value: number  
}  
  
const increment = createAction('counter/increment')  
  
const initialState = { value: 0 } as CounterState  
  
const counterReducer = createReducer(initialState, (builder) => {  
	builder  
		.addCase(increment, (state, action) => {  
			state.value++
		})  
})

Slice (RTK only)

docs of createSlice()

What?

A function that accepts an initial state, an object of reducer functions, and a "slice name", and automatically generates action creators and action types that correspond to the reducers and state.

Example

import { createSlice } from '@reduxjs/toolkit'  
import type { PayloadAction } from '@reduxjs/toolkit'  
  
interface CounterState {  
	value: number  
}  
  
const initialState = { value: 0 } as CounterState  
  
const counterSlice = createSlice({  
	name: 'counter',  
	initialState,  
	reducers: {  
		increment(state) {  
		state.value++  
		},  
		decrement(state) {  
			state.value--  
		},  
		incrementByAmount(state, action: PayloadAction<number>) {  
			state.value += action.payload  
		},  
	},  
})  
  
export const { increment, decrement, incrementByAmount } = counterSlice.actions  
export default counterSlice.reducer

Store

What?

The store is like a big container that holds all the data (state) for your entire application.

Why?

It's the heart of Redux. The store is responsible for keeping track of your application's data and provides a way to access and update that data in a controlled and predictable manner. It ensures that your data is consistent and can be easily shared among different parts of your app.

Example in Redux

configureStore

  • The store is created by passing in a reducer,
  • the store has a method called getState that returns the current state value
import { configureStore } from '@reduxjs/toolkit'  
  
const store = configureStore({ reducer: counterReducer })  
  
console.log(store.getState())  
// {value: 0}

Selectors

  • Selectors are functions that know how to extract specific pieces of information from a store state value.
const selectCounterValue = state => state.value  
  
const currentValue = selectCounterValue(store.getState())  
console.log(currentValue)  
// 2

Dispatch

  • The only way to update the state is to call store.dispatch() and pass in an action object
  • The store will run its reducer function and save the new state value inside.
store.dispatch({ type: 'counter/incremented' })  
  
console.log(store.getState())  
// {value: 1}
  • You can think of dispatching actions as "triggering an event" in the application. Something happened, and we want the store to know about it. Reducers act like event listeners, and when they hear an action they are interested in, they update the state in response.

Example in Redux Toolkit

configureStore

Redux Toolkit's configureStore simplifies the setup process, by doing all the work for you. One call to configureStore will:

  • Call combineReducers to combine your slices reducers into the root reducer function
  • Add the thunk middleware and called applyMiddleware
  • In development, automatically add more middleware to check for common mistakes like accidentally mutating the state
  • Automatically set up the Redux DevTools Extension connection
  • Call createStore to create a Redux store using that root reducer and those configuration options
import { configureStore } from '@reduxjs/toolkit'  
  
import rootReducer from './reducers'  
  
const store = configureStore({ reducer: rootReducer })  
// The store now has redux-thunk added and the Redux DevTools Extension is turned on

createSelector

  • The createSelector utility from the Reselect library, re-exported for ease of use.
  • Reselect's createSelector creates memoized selector functions that only recalculate the output if the inputs change.
import { createSelector } from 'reselect'

const selectValue = state => state.value;

const selectValuePlusOne = createSelector(
	[selectValue],
	(valueabc) => { // `valueabc` is the first arguments return value, i.e., `state.value`
		return valueabc + 1;
	}
)

const exampleState = { value: 5 };
console.log(selectValuePlusOne(exampleState)); // 6

Example in react-redux

useSelector

  • It's used to read data from the store within a component and trigger re-renders when that data changes.
const selectValue = (state: RootState) => state.value
const value = useSelector(selectValue)  
// 0

Q: Why do we not use just selectCounter and wrap it with useSelector? A:

  • useSelector subscribes to the store, and re-runs the selector each time an action is dispatched.
  • When an action is dispatched, useSelector() will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render.
    • Q: Doesn't this mean useSelector memoises?
    • A: Yes and no. The value might be the same, but if its a new reference, the component will still rerender. If we don't want to rerender even if its a different reference, then we use useSelector + createSelector like the following:

source

import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'

const selectNumCompletedTodos = createSelector(
  (state) => state.todos,
  (todos) => todos.filter((todo) => todo.completed).length
)

export const CompletedTodosCounter = () => {
  const numCompletedTodos = useSelector(selectNumCompletedTodos)
  return <div>{numCompletedTodos}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of completed todos:</span>
      <CompletedTodosCounter />
    </>
  )
}

useDispatch

  • dispatches actions
import React from 'react'  
import { useDispatch } from 'react-redux'  
import type { Dispatch } from 'redux'
  
export const CounterComponent = ({ value }) => {  
	const dispatch: Dispatch = useDispatch()  
	  
	return (  
		<div>  
		<span>{value}</span>  
		<button onClick={() => dispatch({ type: 'counter/incremented' })}>  
			Increment counter  
		</button>  
	</div>  
	)  
}

or

// 📂 store/counter/reducer.ts
import { createSlice } from '@reduxjs/toolkit'  
import type { PayloadAction } from '@reduxjs/toolkit'  
  
interface CounterState {  
	value: number  
}  
  
const initialState = { value: 0 } as CounterState  
  
const counterSlice = createSlice({  
	name: 'counter',  
	initialState,  
	reducers: {  
		increment(state) {  
		state.value++  
		},  
		decrement(state) {  
			state.value--  
		},  
		incrementByAmount(state, action: PayloadAction<number>) {  
			state.value += action.payload  
		},  
	},  
})  
  
export const { increment, decrement, incrementByAmount } = counterSlice.actions  
export default counterSlice.reducer

// 📂 components/someFile.tsx
import React from 'react'  
import type { Dispatch } from 'redux'
import { useDispatch } from 'react-redux'  
import { incrementByAmount } from 'store/counter/reducer';
  
export const CounterComponent = ({ value }) => {  
	const dispatch: Dispatch = useDispatch()  
	  
	return (  
		<div>  
		<span>{value}</span>  
		<button onClick={() => dispatch(incrementByAmount(5))}>  
			Increment counter  
		</button>  
	</div>  
	)  
}

Demo of data flow using Redux

![[redux-data-flow.gif]]

Thunk

What?

Historically, a thunk usually refers to a small piece of code that is called as a function, does some small thing, and then JUMPs to another location (usually a function) instead of returning to its caller (source). So, a thunk helps us delay the execution/work of some code. I italicised the word usually because it has more meanings, but they are not our concern right now.

  • For Redux specifically, a thunk calls dispatch and getState methods within the body of the thunk function.
  • Thunk functions are not directly called by application code. Instead, they are passed to store.dispatch():
const thunkFunction = (dispatch, getState) => {  
	// logic here that can dispatch actions or read state 
	// may contain _any_ arbitrary logic, sync or async, and can call `dispatch` or `getState` at any time. 
}  
  
store.dispatch(thunkFunction)

In the same way that Redux code normally uses action creators to generate action objects for dispatching  :

// "action creator" function
const todoAdded = text => {  
	return {  
		type: 'todos/todoAdded',  
		payload: text  
	}  
}

// calling the "action creator" (i.e., todoAdded(...), and then passing the resulting action object directly to dispatch
store.dispatch(todoAdded('Buy milk'))

// whereas the code below is not good practice
dispatch({ type: 'todos/todoAdded', payload: trimmedText })

// why is it bad practice?
// because we have to write that whole thing every single time we want to dispatch that action. It is prone to errors and has lots of duplication

instead of writing action objects by hand, we normally use thunk action creators to generate the thunk functions that are dispatched.

A thunk action creator is a function that may have some arguments, and returns a new thunk function. The thunk typically closes over any arguments passed to the action creator, so they can be used in the logic:

// 📂 someFile.ts
// fetchTodoById is the "thunk action creator"  
export function fetchTodoById(todoId) {  
	// fetchTodoByIdThunk is the "thunk function"  
		return async function fetchTodoByIdThunk(dispatch, getState) {  
			const response = await client.get(`/fakeApi/todo/${todoId}`)  
			dispatch(todosLoaded(response.todos))  
	}
}

// 📂 todoComponent.tsx
function TodoComponent({ todoId }) {  
	const dispatch = useDispatch()  
	  
	const onFetchClicked = () => {  
		// Calls the thunk action creator, and passes the thunk function to dispatch  
		dispatch(fetchTodoById(todoId))  
	}  
}

Why?

  • Thunks allow us to write additional Redux-related logic separate from a UI layer.
    • This logic can include side effects, such as async requests or generating random values, as well as logic that requires dispatching multiple actions or access to the Redux store state.

In a sense, a thunk is a loophole where you can write any code that needs to interact with the Redux store, ahead of time, without needing to know which Redux store will be used.

When?

some use cases:

  • Moving complex logic out of components
  • Making async requests or other async logic
  • Writing logic that needs to dispatch multiple actions in a row or over time
  • Writing logic that needs access to getState to make decisions or include other state values in an action

How? (Example)

Long example of redux-thunk

createAsyncThunk

A function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.

normal Thunk vs createAsyncThunk

Example
Normal thunk
  • takes dispatch and getState arguments
  • dispatches actions within the thunk/function body
const fetchData = () => (dispatch: Dispatch<typeOfPassedParameters>, getState: RootState) => { 
	dispatch({ type: 'FETCH_DATA_REQUEST' });
	 // Perform asynchronous logic, e.g., fetch data from an API 
	 fetch('/api/data') 
		 .then(response => response.json()) 
		 .then(data => { 
			 dispatch({ type: 'FETCH_DATA_SUCCESS', payload: data }); }) 
		.catch(error => { 
			dispatch({ type: 'FETCH_DATA_FAILURE', error }); 
		}); 
};
createAsyncThunk
  • utility function provided by RTK
  • generates a set of action creators (i.e., pending, fulfilled, rejected)

these thunks fit better when using RTK because t

  • createAsyncThunk is fully supported by RTK
  • can handle more action types (e.g., data/fetchData automatically generate the following actions: data/fetchData/pending and data/fetchData/fulfilled etc.)
  • can access more state changing functions using the thunkAPI argument
import { createAsyncThunk } from '@reduxjs/toolkit';

const dataAPI = {
	async function fetch('url') {
		// fetch the url
		// return response
	}
}

const fetchData = createAsyncThunk('data/fetchData', async (_, thunkAPI) => { 
	// const { dispatch } = thunkAPI;
	// dispatch(someAction())
	// const currentState = thunkAPI.getState();
	
	const response = await dataAPI.fetch('/api/data');
	const data = response.json(); 
	return data;
});

const initialState = {  
	someData: [],  
	loading: false,
} as UsersState

// Then, handle actions in your reducers:  
const dataSlice = createSlice({  
	name: 'data',  
	initialState,  
	reducers: {  
		// standard reducer logic, with auto-generated action types per reducer  
	},  
	// !
	extraReducers: (builder) => {  
		// Add reducers for additional action types here, and handle loading state as needed  
		// type: data/fetchData/pending
		[fetchData.pending]: state => {
			state.loading = true
		},
		// type: data/fetchData/fulfilled
		[fetchData.fulfilled]: (state, { payload }) => {
			state.loading = false;
			state.data = payload;
		},

		// same as above
		builder.addCase(fetchData.fulfilled, (state, action) => {   
			state.loading = false;
			state.data = payload;
		})
	},  
})  
  
// Later, dispatch the thunk as needed in the app  
dispatch(fetchData())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment