What You'll Learn
In Part 1: Redux Overview and Concepts, we looked at why Redux is useful, the terms and concepts used to describe different parts of Redux code, and how data flows through a Redux app.
Now, let's look at a real working example to see how these pieces fit together.
The Counter Example AppThe sample project we'll look at is a small counter application that lets us add or subtract from a number as we click buttons. It may not be very exciting, but it shows all the important pieces of a React+Redux application in action.
The project has been created using a smaller version of the official Redux Toolkit template for Vite. Out of the box, it has already been configured with a standard Redux application structure, using Redux Toolkit to create the Redux store and logic, and React-Redux to connect together the Redux store and the React components.
Here's the live version of the project. You can play around with it by clicking the buttons in the app preview on the right, and browse through the source files on the left.
If you'd like to set up this project on your own computer, you can create a local copy with this command:
npx degit reduxjs/redux-templates/packages/rtk-app-structure-example my-app
You can also create a new project using the full Redux Toolkit template for Vite:
npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
Using the Counter App
The counter app has already been set up to let us watch what happens inside as we use it.
Open up your browser's DevTools. Then, choose the "Redux" tab in the DevTools, and click the "State" button in the upper-right toolbar. You should see something that looks like this:
On the right, we can see that our Redux store is starting off with an app state value that looks like this:
{
counter: {
value: 0
status: 'idle'
}
}
The DevTools will show us how the store state changes as we use the app.
Let's play with the app first to see what it does. Click the "+" button in the app, then look at the "Diff" tab in the Redux DevTools:
We can see two important things here:
"counter/increment"
was dispatched to the storestate.counter.value
field changed from 0
to 1
Now try these steps:
Go back to the Redux DevTools. You should see a total of five actions dispatched, one for each time we clicked a button . Now select the last "counter/incrementByAmount"
entry from the list on the left, and click the "Action" tab on the right side:
We can see that this action object looked like this:
{
type: 'counter/incrementByAmount',
payload: 3
}
And if you click the "Diff" tab, you can see that the state.counter.value
field changed from a 3
to a 6
in response to that action.
The ability to see what is happening inside of our app and how our state is changing over time is very powerful!
The DevTools have several more commands and options to help you debug your app. Try clicking the "Trace" tab in the upper right. You should see a JavaScript function stack trace in the panel, with several sections of source code showing the lines that were executing when the action reached the store. One line in particular should be highlighted: the line of code where we dispatched this action from the <Counter>
component:
This makes it easier to trace what part of the code dispatched a specific action.
Application ContentsNow that you know what the app does, let's look at how it works.
Here are the key files that make up this application:
/src
main.tsx
: the starting point for the appApp.tsx
: the top-level React component/app
store.ts
: creates the Redux store instancehooks.ts
: exports pre-typed React-Redux hooks/features
/counter
Counter.tsx
: a React component that shows the UI for the counter featurecounterSlice.ts
: the Redux logic for the counter featureLet's start by looking at how the Redux store is created.
Creating the Redux StoreOpen up app/store.ts
, which should look like this:
app/store.ts
import type { Action, ThunkAction } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
export type AppStore = typeof store
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>
The Redux store is created using the configureStore
function from Redux Toolkit. configureStore
requires that we pass in a reducer
argument.
Our application might be made up of many different features, and each of those features might have its own reducer function. When we call configureStore
, we can pass in all of the different reducers in an object. The key names in the object will define the keys in our final state value.
We have a file named features/counter/counterSlice.ts
that exports a reducer function for the counter logic as the ESM "default" export. We can import that function into this file. Since it's a default export, we can give that variable any name we want when we import it into this file. In this case, we call it counterReducer
here, and include it when we create the store. (Note that the import/export behavior here is standard ES Module syntax, and not specific to Redux.)
When we pass in an object like {counter: counterReducer}
, that says that we want to have a state.counter
section of our Redux state object, and that we want the counterReducer
function to be in charge of deciding if and how to update the state.counter
section whenever an action is dispatched.
Redux allows store setup to be customized with different kinds of plugins ("middleware" and "enhancers"). configureStore
automatically adds several middleware to the store setup by default to provide a good developer experience, and also sets up the store so that the Redux DevTools Extension can inspect its contents.
For TypeScript usage, we also want to export some reusable types based on the Store, such as the RootState
and AppDispatch
types. We'll see how those get used later.
A "slice" is a collection of Redux reducer logic and actions for a single feature in your app, typically defined together in a single file. The name comes from splitting up the root Redux state object into multiple "slices" of state.
For example, in a blogging app, our store setup might look like:
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
import postsReducer from '../features/posts/postsSlice'
import commentsReducer from '../features/comments/commentsSlice'
export const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
})
In that example, state.users
, state.posts
, and state.comments
are each a separate "slice" of the Redux state. Since usersReducer
is responsible for updating the state.users
slice, we refer to it as a "slice reducer" function.
A Redux store needs to have a single "root reducer" function passed in when it's created. So if we have many different slice reducer functions, how do we get a single root reducer instead, and how does this define the contents of the Redux store state?
If we tried calling all of the slice reducers by hand, it might look like this:
function rootReducer(state = {}, action) {
return {
users: usersReducer(state.users, action),
posts: postsReducer(state.posts, action),
comments: commentsReducer(state.comments, action)
}
}
That calls each slice reducer individually, passes in the specific slice of the Redux state, and includes each return value in the final new Redux state object.
Redux has a function called combineReducers
that does this for us automatically. It accepts an object full of slice reducers as its argument, and returns a function that calls each slice reducer whenever an action is dispatched. The result from each slice reducer are all combined together into a single object as the final result. We can do the same thing as the previous example using combineReducers
:
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
})
When we pass an object of slice reducers to configureStore
, it passes those to combineReducers
for us to generate the root reducer.
As we saw earlier, you can also pass a reducer function directly as the reducer
argument:
const store = configureStore({
reducer: rootReducer
})
Creating Slice Reducers and Actions
Since we know that the counterReducer
function is coming from features/counter/counterSlice.ts
, let's see what's in that file, piece by piece.
features/counter/counterSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface CounterState {
value: number
status: 'idle' | 'loading' | 'failed'
}
const initialState: CounterState = {
value: 0,
status: 'idle'
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
Earlier, we saw that clicking the different buttons in the UI dispatched three different Redux action types:
{type: "counter/increment"}
{type: "counter/decrement"}
{type: "counter/incrementByAmount"}
We know that actions are plain objects with a type
field, the type
field is always a string, and we typically have "action creator" functions that create and return the action objects. So where are those action objects, type strings, and action creators defined?
We could write those all by hand, every time. But, that would be tedious. Besides, what's really important in Redux is the reducer functions, and the logic they have for calculating new state.
Redux Toolkit has a function called createSlice
, which takes care of the work of generating action type strings, action creator functions, and action objects. All you have to do is define a name for this slice, write an object that has some reducer functions in it, and it generates the corresponding action code automatically. The string from the name
option is used as the first part of each action type, and the key name of each reducer function is used as the second part. So, the "counter"
name + the "increment"
reducer function generated an action type of {type: "counter/increment"}
. (After all, why write this by hand if the computer can do it for us!)
In addition to the name
field, createSlice
needs us to pass in the initial state value for the reducers, so that there is a state
the first time it gets called. In this case, we're providing an object with a value
field that starts off at 0, and a status
field that starts off with 'idle'
.
We can see here that there are three reducer functions, and that corresponds to the three different action types that were dispatched by clicking the different buttons.
createSlice
automatically generates action creators with the same names as the reducer functions we wrote. We can check that by calling one of them and seeing what it returns:
console.log(counterSlice.actions.increment())
It also generates the slice reducer function that knows how to respond to all these action types:
const newState = counterSlice.reducer(
{ value: 10 },
counterSlice.actions.increment()
)
console.log(newState)
Rules of Reducers
We said earlier that reducers must always follow some special rules:
state
and action
argumentsstate
. Instead, they must make immutable updates, by copying the existing state
and making changes to the copied values.But why are these rules important? There are a few different reasons:
The rule about "immutable updates" is particularly important, and worth talking about further.
Reducers and Immutable UpdatesEarlier, we talked about "mutation" (modifying existing object/array values) and "immutability" (treating values as something that cannot be changed).
In Redux, our reducers are never allowed to mutate the original / current state values!
There are several reasons why you must not mutate state in Redux:
So if we can't change the originals, how do we return an updated state?
tip
Reducers can only make copies of the original values, and then they can mutate the copies.
return {
...state,
value: 123
}
We already saw that we can write immutable updates by hand, by using JavaScript's array / object spread operators and other functions that return copies of the original values. However, if you're thinking that "writing immutable updates by hand this way looks hard to remember and do correctly"... yeah, you're right! :)
Writing immutable update logic by hand is hard, and accidentally mutating state in reducers is the single most common mistake Redux users make.
That's why Redux Toolkit's createSlice
function lets you write immutable updates an easier way!
createSlice
uses a library called Immer inside. Immer uses a special JS tool called a Proxy
to wrap the data you provide, and lets you write code that "mutates" that wrapped data. But, Immer tracks all the changes you've tried to make, and then uses that list of changes to return a safely immutably updated value, as if you'd written all the immutable update logic by hand.
So, instead of this:
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
You can write code that looks like this:
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
That's a lot easier to read!
But, here's something very important to remember:
warning
You can only write "mutating" logic in Redux Toolkit's createSlice
and createReducer
because they use Immer inside! If you write mutating logic in your code without Immer, it will mutate the state and cause bugs!
With that in mind, let's go back and look at the actual reducers from the counter slice.
features/counter/counterSlice.ts
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: state => {
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
We can see that the increment
reducer will always add 1 to state.value
. Because Immer knows we've made changes to the draft state
object, we don't have to actually return anything here. In the same way, the decrement
reducer subtracts 1.
In both of those reducers, we don't actually need to have our code look at the action
object. It will be passed in anyway, but since we don't need it, we can skip declaring action
as a parameter for the reducers.
On the other hand, the incrementByAmount
reducer does need to know something: how much it should be adding to the counter value. So, we declare the reducer as having both state
and action
arguments. In this case, we know that the amount we typed into the "amount" input is being put into the action.payload
field, so we can add that to state.value
.
If we're using TypeScript, we need to tell TS what the type of action.payload
will be. The PayloadAction
type declares that "this is an action object, where the type of action.payload
is..." whatever type you supplied. In this case, we know that the UI has taken the numeric string that was typed into the "amount" textbox, converted it into a number, and is trying to dispatch the action with that value, so we'll declare that this is action: PayloadAction<number>
.
The core of Redux is reducers, actions, and the store. There's a couple additional types of Redux functions that are commonly used as well.
Reading Data with SelectorsWe can call store.getState()
to get the entire current root state object, and access its fields like state.counter.value
.
It's standard to write "selector" functions that do those state field lookups for us. In this case, counterSlice.ts
exports two selector functions that can be reused:
export const selectCount = (state: RootState) => state.counter.value
export const selectStatus = (state: RootState) => state.counter.status
Selector functions are normally called with the entire Redux root state object as an argument. They can read out specific values from the root state, or do calculations and return new values.
Since we're using TypeScript, we also need to use the RootState
type that was exported from store.ts
to define the type of the state
argument in each selector.
Note that you don't have to create separate selector functions for every field in every slice! (This particular example did, to show off the idea of writing selectors, but we only had two fields in counterSlice.ts
anyway) Instead, find a balance in how many selectors you write.
So far, all the logic in our application has been synchronous. Actions are dispatched, the store runs the reducers and calculates the new state, and the dispatch function finishes. But, the JavaScript language has many ways to write code that is asynchronous, and our apps normally have async logic for things like fetching data from an API. We need a place to put that async logic in our Redux apps.
A thunk is a specific kind of Redux function that can contain asynchronous logic. Thunks are written using two functions:
dispatch
and getState
as argumentsThe next function that's exported from counterSlice
is an example of a thunk action creator:
features/counter/counterSlice.ts
export const incrementIfOdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount))
}
}
}
In this thunk, we use getState()
to get the store's current root state value, and dispatch()
to dispatch another action. We could easily put async logic here as well, such as a setTimeout
or an await
.
We can use them the same way we use a typical Redux action creator:
store.dispatch(incrementIfOdd(6))
Using thunks requires that the redux-thunk
middleware (a type of plugin for Redux) be added to the Redux store when it's created. Fortunately, Redux Toolkit's configureStore
function already sets that up for us automatically, so we can go ahead and use thunks here.
When writing thunks, we need to make sure the dispatch
and getState
methods are typed correctly. We could define the thunk function as (dispatch: AppDispatch, getState: () => RootState)
, but it's standard to define a reusable AppThunk
type for that in the store file.
When you need to make HTTP calls to fetch data from the server, you can put that call in a thunk. Here's an example that's written a bit longer, so you can see how it's defined:
Example handwritten async thunk
const fetchUserById = (userId: string): AppThunk => {
return async (dispatch, getState) => {
try {
dispatch(userPending())
const user = await userAPI.fetchById(userId)
dispatch(userLoaded(user))
} catch (err) {
}
}
}
Redux Toolkit includes a createAsyncThunk
method that does all of the dispatching work for you. The next function in counterSlice.ts
is an async thunk that makes a mock API request with a counter value. When we dispatch this thunk, it will dispatch a pending
action before making the request, and either a fulfilled
or rejected
action after the async logic is done.
features/counter/counterSlice.ts
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
async (amount: number) => {
const response = await fetchCount(amount)
return response.data
}
)
When you use createAsyncThunk
, you handle its actions in createSlice.extraReducers
. In this case, we handle all three action types, update the status
field, and also update the value
:
features/counter/counterSlice.ts
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
},
extraReducers: builder => {
builder
.addCase(incrementAsync.pending, state => {
state.status = 'loading'
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = 'idle'
state.value += action.payload
})
.addCase(incrementAsync.rejected, state => {
state.status = 'failed'
})
}
})
If you're curious why we use thunks for async logic, see this deeper explanation:
Detailed Explanation: Thunks and Async LogicWe know that we're not allowed to put any kind of async logic in reducers. But, that logic has to live somewhere.
If we had access to the Redux store, we could write some async code and call store.dispatch()
when we're done:
const store = configureStore({ reducer: counterReducer })
setTimeout(() => {
store.dispatch(increment())
}, 250)
But, in a real Redux app, we're not allowed to import the store into other files, especially in our React components, because it makes that code harder to test and reuse.
In addition, we often need to write some async logic that we know will be used with some store, eventually, but we don't know which store.
The Redux store can be extended with "middleware", which are a kind of add-on or plugin that can add extra abilities. The most common reason to use middleware is to let you write code that can have async logic, but still talk to the store at the same time. They can also modify the store so that we can call dispatch()
and pass in values that are not plain action objects, like functions or Promises.
The Redux Thunk middleware modifies the store to let you pass functions into dispatch
. In fact, it's short enough we can paste it here:
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
It looks to see if the "action" that was passed into dispatch
is actually a function instead of a plain action object. If it's actually a function, it calls the function, and returns the result. Otherwise, since this must be an action object, it passes the action forward to the store.
This gives us a way to write whatever sync or async code we want, while still having access to dispatch
and getState
.
Earlier, we saw what a standalone React <Counter>
component looks like. Our React+Redux app has a similar <Counter>
component, but it does a few things differently.
We'll start by looking at the Counter.tsx
component file:
features/counter/Counter.tsx
import { useState } from 'react'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import {
decrement,
increment,
incrementAsync,
incrementByAmount,
incrementIfOdd,
selectCount,
selectStatus
} from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const dispatch = useAppDispatch()
const count = useAppSelector(selectCount)
const status = useAppSelector(selectStatus)
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => {
dispatch(decrement())
}}
>
-
</button>
<span aria-label="Count" className={styles.value}>
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
{}
</div>
</div>
)
}
Like with the earlier plain React example, we have a function component called Counter
, that stores some data in a useState
hook.
However, in our component, it doesn't look like we're storing the actual current counter value as state. There is a variable called count
, but it's not coming from a useState
hook.
While React includes several built-in hooks like useState
and useEffect
, other libraries can create their own custom hooks that use React's hooks to build custom logic.
The React-Redux library has a set of custom hooks that allow your React component to interact with a Redux store.
Reading Data withuseSelector
First, the useSelector
hook lets our component extract whatever pieces of data it needs from the Redux store state.
Earlier, we saw that we can write "selector" functions, which take state
as an argument and return some part of the state value. In particular, our counterSlice.ts
file is exporting selectCount
and selectStatus
If we had access to a Redux store, we could retrieve the current counter value as:
const count = selectCount(store.getState())
console.log(count)
Our components can't talk to the Redux store directly, because we're not allowed to import it into component files. But, useSelector
takes care of talking to the Redux store behind the scenes for us. If we pass in a selector function, it calls someSelector(store.getState())
for us, and returns the result.
So, we can get the current store counter value by doing:
const count = useSelector(selectCount)
We don't have to only use selectors that have already been exported, either. For example, we could write a selector function as an inline argument to useSelector
:
const countPlusTwo = useSelector((state: RootState) => state.counter.value + 2)
Any time an action has been dispatched and the Redux store has been updated, useSelector
will re-run our selector function. If the selector returns a different value than last time, useSelector
will make sure our component re-renders with the new value.
useDispatch
Similarly, we know that if we had access to a Redux store, we could dispatch actions using action creators, like store.dispatch(increment())
. Since we don't have access to the store itself, we need some way to have access to just the dispatch
method.
The useDispatch
hook does that for us, and gives us the actual dispatch
method from the Redux store:
const dispatch = useDispatch()
From there, we can dispatch actions when the user does something like clicking on a button:
features/counter/Counter.tsx
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
Defining Pre-Typed React-Redux Hooks
By default the useSelector
hook needs you to declare (state: RootState)
for every selector function. We can create pre-typed versions of the useSelector
and useDispatch
hooks so that we don't have to keep repeating the : RootState
part every time.
app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
Then, we can import the useAppSelector
and useAppDispatch
hooks into our own components and use them instead of the original versions.
By now you might be wondering, "Do I always have to put all my app's state into the Redux store?"
The answer is NO. Global state that is needed across the app should go in the Redux store. State that's only needed in one place should be kept in component state.
In this example, we have an input textbox where the user can type in the next number to be added to the counter:
features/counter/Counter.tsx
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
return (
<div className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
</div>
)
We could keep the current number string in the Redux store, by dispatching an action in the input's onChange
handler and keeping it in our reducer. But, that doesn't give us any benefit. The only place that text string is used is here, in the <Counter>
component. (Sure, there's only one other component in this example: <App>
. But even if we had a larger application with many components, only <Counter>
cares about this input value.)
So, it makes sense to keep that value in a useState
hook here in the <Counter>
component.
Similarly, if we had a boolean flag called isDropdownOpen
, no other components in the app would care about that - it should really stay local to this component.
In a React + Redux app, your global state should go in the Redux store, and your local state should stay in React components.
If you're not sure where to put something, here are some common rules of thumb for determining what kind of data should be put into Redux:
This is also a good example of how to think about forms in Redux in general. Most form state probably shouldn't be kept in Redux. Instead, keep the data in your form components as you're editing it, and then dispatch Redux actions to update the store when the user is done.
One other thing to note before we move on: remember that incrementAsync
thunk from counterSlice.ts
? We're using it here in this component. Notice that we use it the same way we dispatch the other normal action creators. This component doesn't care whether we're dispatching a normal action or starting some async logic. It only knows that when you click that button, it dispatches something.
We've seen that our components can use the useSelector
and useDispatch
hooks to talk to the Redux store. But, since we didn't import the store, how do those hooks know what Redux store to talk to?
Now that we've seen all the different pieces of this application, it's time to circle back to the starting point of this application and see how the last pieces of the puzzle fit together.
main.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import { store } from './app/store'
import './index.css'
const container = document.getElementById('root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
We always have to call root.render(<App />)
to tell React to start rendering our root <App>
component. In order for our hooks like useSelector
to work right, we need to use a component called <Provider>
to pass down the Redux store behind the scenes so they can access it.
We already created our store in app/store.ts
, so we can import it here. Then, we put our <Provider>
component around the whole <App>
, and pass in the store: <Provider store={store}>
.
Now, any React components that call useSelector
or useDispatch
will be talking to the Redux store we gave to the <Provider>
.
Even though the counter example app is pretty small, it showed all the key pieces of a React + Redux app working together. Here's what we covered:
Summary
configureStore
API
configureStore
accepts a reducer
function as a named argumentconfigureStore
automatically sets up the store with good default settingscreateSlice
API generates action creators and action types for each individual reducer function you providestate
and action
argumentscreateSlice
API uses Immer to allow "mutating" immutable updates(state: RootState)
as their argument and either return a value from the state, or derive a new valueuseSelector
hookdispatch
and getState
as argumentsredux-thunk
middleware by default<Provider store={store}>
enables all components to use the storeuseSelector
hook lets React components read values from the Redux storeuseDispatch
hook lets components dispatch actionsuseAppSelector
and useAppDispatch
hooksNow that you've seen all the pieces of a Redux app in action, it's time to write your own! For the rest of this tutorial, you'll be building a larger example app that uses Redux. Along the way, we'll cover all the key ideas you need to know to use Redux the right way.
Continue on to Part 3: Basic Redux Data Flow to get started building the example app.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4