24
loading...
This website collects cookies to deliver better user experience
Redux Toolkit
, what problems it solves and when it can be useful for your projects.Pokemon Trading Card Game
cards with prices and the option to add them to a cart to proceed to checkout.RTK Query
is used to fetch data from a third party API and how the Redux Toolkit
handles client state for the cart logic.Redux Toolkit
works and avoid all the historical stuff, skip this section 😁.class components
, which at that time was the only component type that had state, we defined a state and mutated it through setState
.class Clock extends React.Component {
constructor(props) {
super(props);
this.state = { date: new Date() };
}
componentDidMount() {
this.timerID = setInterval(() => this.tick(), 1000);
}
componentWillUnmount() {
clearInterval(this.timerID);
}
tick() {
this.setState({ date: new Date() });
}
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}
ReactDOM.render(<Clock />, document.getElementById("root"));
prop drilling
, back then there was no Context
and you were forced to go through the tree every state you needed, that's why the idea of having a global state and plugging it in where you needed it became so popular, but that's just the next point.import * as actionTypes from '../actions/actionsTypes';
const initialState = {
orders: [],
loading: false,
purchased: false
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case actionTypes.PURCHASE_INIT:
return {
...state,
purchased: false
};
case actionTypes.PURCHASE_START:
return {
...state,
loading: true
};
case actionTypes.PURCHASE_SUCCESS:
const newOrder = {
...action.orderData,
id: action.orderId
};
return {
...state,
loading: false,
orders: state.orders.concat(newOrder),
purchased: true
};
case actionTypes.PURCHASE_FAIL:
return {
...state,
loading: false
};
case actionTypes.FETCH_ORDERS_START:
return {
...state,
loading: true
};
case actionTypes.FETCH_ORDERS_SUCCESS:
return {
...state,
loading: false,
orders: action.orders
};
case actionTypes.FETCH_ORDERS_FAIL:
return {
...state,
loading: false
};
default:
return state;
}
};
export default reducer;
const purchaseSuccess = (id, orderData) => {
return {
type: actionTypes.PURCHASE_SUCCESS,
orderId: id,
orderData
};
};
const purchaseFail = error => {
return {
type: actionTypes.PURCHASE_FAIL,
error
};
};
const purchaseStart = () => {
return {
type: actionTypes.PURCHASE_START
};
};
const Orders = () => {
// ...
}
const mapStateToProps = state => {
return {
orders: state.order.orders,
loading: state.order.loading
};
};
const mapDispatchToProps = dispatch => {
return {
onFetchOrders: () => dispatch(actions.fetchOrders())
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Orders);
useState
, useContext
,useEffect
and the less used but no less effective useReducer
came to save the day.useState
can be used multiple times so I don't fall into complex nested objects, useContext
eliminates props drilling so.... everything was nice and shiny but... Redux Dev Tools
was and still is a blast.START
, SUCCESS
and ERROR
was still there but at least the connection and mapping was easier:mapStateToProps
was replaced by useSelector
.const { video: currentVideo } = useSelector(
(state: AppState) => state.CurrentVideo
);
mapDispatchToProps
was replaced by a combination of useDispatch
and the functions directly:const dispatch = useDispatch();
dispatch(fetchVideoWithExtraInfo(page));
connect
the component "magically" got new props, but with useSelector
and useDispatch
it is clear where that data comes from and why you have access to it.const { isLoading, error, data } = useQuery('repoData', () =>
fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res =>
res.json()
)
)
START
, SUCCESS
and ERROR
but with almost 0 config and no boilerplate code. In this case data
will contain the data fetched from the API, already cached and merged with the updates, and the other params will tell you the status directly.repoData
query key in this case, it will work. const {
status,
data,
error,
isFetching,
isFetchingMore,
fetchMore,
canFetchMore
} = useInfiniteQuery('fetchPokemons', fetchPokemons, {
initialData: [initialPokemonList],
getFetchMore: lastGroup => lastGroup?.next
});
Redux Toolkit
appeared and I felt like someone read my mind and I started tinkering with it.RTK Query
is a package included in the Toolkit that will introduce more or less the same magic as react-query
and all that boilerplate code will be MUCH reduced.Redux Toolkit
and React-Redux
.npm install @reduxjs/toolkit react-redux
// app/store.ts
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {}
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
configureStore
also has the Redux Dev Tools enabled, which in previous versions you needed to put in some "weird" code to enable it. Also reducer
will do the job of the old combine reducers.// pages/_app.tsx
import { AppProps } from 'next/app'
import { Provider } from 'react-redux'
import { store } from 'app/store'
import 'styles/globals.css'
function MyApp({ Component, pageProps }: AppProps) {
return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
)
}
export default MyApp
NextJS
I'm going to add the same example in React:// src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import store from './app/store'
import './index.css'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
useSelector
and useDispatch
don't know the types and capabilities of our application, but we can create custom versions that do:// app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import { RootState, AppDispatch } from 'app/store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
react-redux
. As you can see, we are providing the types we created earlier in the store file.slice
, in previous versions of Redux at this point you will create a reducer
and actions
for your desired feature, which in this case will be the Cart
of our Pokemon TCG shop that will contain the different cards that we place in the cart to buy them later in a purchase process.slice
that will contain all the logic and data of a portion of our Redux state, in this case the portion referring to the cart:// features/Cart/cart-slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { IPokemonCard } from 'components/Card'
export interface IStoredPokemonCard extends IPokemonCard {
uuid: string
}
interface CartState {
cards: IStoredPokemonCard[]
}
const initialState: CartState = {
cards: [],
}
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<IStoredPokemonCard>) {
const pokemonCard = action.payload
state.cards.push(pokemonCard)
},
removeItem(state, action: PayloadAction<string>) {
const pokemonCardUUID = action.payload
const cards = state.cards.filter(({ uuid }) => uuid !== pokemonCardUUID)
state.cards = cards
},
},
})
export const { addItem, removeItem } = cartSlice.actions
export default cartSlice.reducer
createSlice
is our main function to create the slice.PayloadAction
is a TS type to check what is coming from the component.initialState
will be the initial state of this slice when it is created, in this case, an empty array of Pokemon cards.name
which, as we will see later, will be used to name different things as well as being the unique identifier of the slice.reducers
will contain the update logic for our part of the shop, in this case how we handle adding new cards to the cart, and removing them.cartSlice.actions
is what we were putting in the actions
file so far, but with createSlice
they are created automatically.reducers: {
addItem(state, action: PayloadAction<IStoredPokemonCard>) {
const pokemonCard = action.payload
return {
...state,
cards: [...state.cards, pokemonCard]
}
},
removeItem(state, action: PayloadAction<string>) {
const pokemonCardUUID = action.payload
return {
...state,
cards: state.cards.filter(({ uuid }) => uuid !== pokemonCardUUID)
}
},
},
// app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from 'features/cart/cart-slice'
export const store = configureStore({
reducer: {
cart: cartReducer,
}
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
store
set up and we have already made a slice
that contains the logic and data for our cart function, let's use what we have so far to make the Cart
.// features/cart/Cart.tsx
// import { useDispatch, useSelector } from 'react-redux'
import { useAppDispatch, useAppSelector } from 'app/hooks'
import Card from 'components/Card'
import { removeItem } from './cart-slice'
export default function Cart() {
const { cards } = useAppSelector((state) => state.cart)
const dispatch = useAppDispatch()
const totalPrice = cards
.reduce((acc, card) => acc + card.cardmarket.prices.averageSellPrice, 0)
.toFixed(2)
return (
<div>
<div>Total Price: {totalPrice}</div>
{cards?.map((card) => (
<Card
flavor="item"
key={card.uuid}
{...card}
onRemove={() => dispatch(removeItem(card.uuid!))}
/>
))}
</div>
)
}
useAppDispatch
and useAppSelector
instead of the generic react-redux
versions, this is for TS users only.cards
from the state.cart
.removeItem
action.name
of the slice is also used for the different actions
created automatically:RTK Query
and what improvements this tool, which is part of Redux Toolkit
, brings.import { createApi } from '@reduxjs/toolkit/query/react'
// features/pokemonTCGAPI/pokemon-tcg-api-slice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { ORDER_BY } from './OrderBy'
import { IResponse } from './types'
interface IQueryParams {
name?: string
page?: number
pageSize?: number
orderBy?: string
}
export const apiSlice = createApi({
reducerPath: 'pokemon-tcg-api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.pokemontcg.io/v2',
}),
endpoints(builder) {
return {
fetchCards: builder.query<IResponse, IQueryParams | void>({
query({
name = '',
page = 1,
pageSize = 20,
orderBy = ORDER_BY.SET_RELEASE_DATE,
}: IQueryParams) {
const queryName = name ? `&q=name:${name}` : ''
return `/cards?page=${page}&pageSize=${pageSize}&orderBy=${orderBy}${queryName}`
},
}),
}
},
})
export const { useFetchCardsQuery } = apiSlice
createApi
:reducerPath
will be the name of where we store the data in the store
, and will be used for a few more things we'll see later.baseQuery
specifies how to get the data, in this case fetchBaseQuery
is already built into RTK Query and is a wrapper around fetch
, we also specify a baseUrl
which will be used in the different queries.endpoints
object will return an object with the different endpoints available, RTK Query will auto-generate the hooks for those endpoints as you see in the last line for useFetchCardsQuery
.fetchCards
which will call https://api.pokemontcg.io/v2/cards with a bunch of parameters to perform the search.thunk
in an earlier version of Redux and you'll see how much simpler it is now.middleware
:// app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from 'features/cart/cart-slice'
import { apiSlice } from 'features/pokemonTCGAPI/pokemon-tcg-api-slice'
export const store = configureStore({
reducer: {
cart: cartReducer,
[apiSlice.reducerPath]: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware().concat(apiSlice.middleware)
},
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
apiSlice
of our newly created slice.reducerPath
we name the reducer and as I said before, the reducer
is provided automatically.// pages/index.tsx
import { useState } from 'react'
import { useFetchCardsQuery } from 'features/pokemonTCGAPI/pokemon-tcg-api-slice'
import { ORDER_BY } from 'features/pokemonTCGAPI/OrderBy'
export default function Home() {
const [inputName, setInputName] = useState('')
const [name, setName] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [orderBy, setOrderBy] = useState(ORDER_BY.AVERAGE_SELL_PRICE)
const { data, isFetching, isLoading, isError } = useFetchCardsQuery({
name,
page,
pageSize,
orderBy,
})
if (isFetching || isLoading) return <div>Loading...</div>
if (isError) return <div>Error</div>
return (
<div>
{data &&
data.data.map((card) => {
return <div key={card.id}>{card.name}</div>
})}
</div>
)
}
useFetchCardsQuery
that we generated earlier and return:data
which will have the response from the API call.isFetching
and isLoading
will be our old friend LOADING
action.isError
will be the ERROR
action.pokemon-tcg-api-slice
you can also export a function called useLazyFetchCardsQuery
that will be called when you call the trigger
method.const { data, isFetching, isLoading, isError, trigger } = useLazyFetchCardsQuery({
name,
page,
pageSize,
orderBy,
})
react-query
+ React Hooks and I'm happy with that solution, but I think most of my concerns about using Redux are gone. Redux Toolkit
or react-query
.