redux store setup

This commit is contained in:
bedlam343 2024-03-23 22:17:11 -07:00
parent 3c1f32980d
commit 09409907e2
21 changed files with 58 additions and 580 deletions

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Custom
reduxDemo

View File

@ -1,4 +1,4 @@
{ {
"semi": false, "semi": true,
"arrowParens": "avoid" "arrowParens": "avoid"
} }

View File

@ -1,21 +1,7 @@
import "./App.css" import "src/App.css";
import { Counter } from "./features/counter/Counter"
import { Quotes } from "./features/quotes/Quotes"
import logo from "./logo.svg"
const App = () => { const App = () => {
return ( return <div>App</div>;
<div className="App"> };
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<Counter />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<Quotes />
</header>
</div>
)
}
export default App export default App;

View File

@ -1,6 +0,0 @@
import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"
// `buildCreateSlice` allows us to create a slice with async thunks.
export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator },
})

View File

@ -1,42 +0,0 @@
import type { Action, ThunkAction } from "@reduxjs/toolkit"
import { combineSlices, configureStore } from "@reduxjs/toolkit"
import { setupListeners } from "@reduxjs/toolkit/query"
import { counterSlice } from "../features/counter/counterSlice"
import { quotesApiSlice } from "../features/quotes/quotesApiSlice"
// `combineSlices` automatically combines the reducers using
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
const rootReducer = combineSlices(counterSlice, quotesApiSlice)
// Infer the `RootState` type from the root reducer
export type RootState = ReturnType<typeof rootReducer>
// The store setup is wrapped in `makeStore` to allow reuse
// when setting up tests that need the same store config
export const makeStore = (preloadedState?: Partial<RootState>) => {
const store = configureStore({
reducer: rootReducer,
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware => {
return getDefaultMiddleware().concat(quotesApiSlice.middleware)
},
preloadedState,
})
// configure listeners using the provided defaults
// optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors
setupListeners(store.dispatch)
return store
}
export const store = makeStore()
// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = AppStore["dispatch"]
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>

View File

@ -1,81 +0,0 @@
.row {
display: flex;
align-items: center;
justify-content: center;
}
.row > button {
margin-left: 4px;
margin-right: 8px;
}
.row:not(:last-child) {
margin-bottom: 16px;
}
.value {
font-size: 78px;
padding-left: 16px;
padding-right: 16px;
margin-top: 2px;
font-family: "Courier New", Courier, monospace;
}
.button {
appearance: none;
background: none;
font-size: 32px;
padding-left: 12px;
padding-right: 12px;
outline: none;
border: 2px solid transparent;
color: rgb(112, 76, 182);
padding-bottom: 4px;
cursor: pointer;
background-color: rgba(112, 76, 182, 0.1);
border-radius: 2px;
transition: all 0.15s;
}
.textbox {
font-size: 32px;
padding: 2px;
width: 64px;
text-align: center;
margin-right: 4px;
}
.button:hover,
.button:focus {
border: 2px solid rgba(112, 76, 182, 0.4);
}
.button:active {
background-color: rgba(112, 76, 182, 0.2);
}
.asyncButton {
composes: button;
position: relative;
}
.asyncButton:after {
content: "";
background-color: rgba(112, 76, 182, 0.15);
display: block;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
opacity: 0;
transition:
width 1s linear,
opacity 0.5s ease 1s;
}
.asyncButton:active:after {
width: 0%;
opacity: 1;
transition: 0s;
}

View File

@ -1,78 +0,0 @@
import { useState } from "react"
import { useAppDispatch, useAppSelector } from "../../app/hooks"
import styles from "./Counter.module.css"
import {
decrement,
increment,
incrementAsync,
incrementByAmount,
incrementIfOdd,
selectCount,
selectStatus,
} from "./counterSlice"
export const 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 className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
type="number"
onChange={e => {
setIncrementAmount(e.target.value)
}}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
disabled={status !== "idle"}
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
<button
className={styles.button}
onClick={() => {
dispatch(incrementIfOdd(incrementValue))
}}
>
Add If Odd
</button>
</div>
</div>
)
}

View File

@ -1,6 +0,0 @@
// A mock function to mimic making an async request for data
export const fetchCount = (amount = 1) => {
return new Promise<{ data: number }>(resolve =>
setTimeout(() => resolve({ data: amount }), 500),
)
}

View File

@ -1,58 +0,0 @@
import type { AppStore } from "../../app/store"
import { makeStore } from "../../app/store"
import type { CounterSliceState } from "./counterSlice"
import {
counterSlice,
decrement,
increment,
incrementByAmount,
selectCount,
} from "./counterSlice"
interface LocalTestContext {
store: AppStore
}
describe<LocalTestContext>("counter reducer", it => {
beforeEach<LocalTestContext>(context => {
const initialState: CounterSliceState = {
value: 3,
status: "idle",
}
const store = makeStore({ counter: initialState })
context.store = store
})
it("should handle initial state", () => {
expect(counterSlice.reducer(undefined, { type: "unknown" })).toStrictEqual({
value: 0,
status: "idle",
})
})
it("should handle increment", ({ store }) => {
expect(selectCount(store.getState())).toBe(3)
store.dispatch(increment())
expect(selectCount(store.getState())).toBe(4)
})
it("should handle decrement", ({ store }) => {
expect(selectCount(store.getState())).toBe(3)
store.dispatch(decrement())
expect(selectCount(store.getState())).toBe(2)
})
it("should handle incrementByAmount", ({ store }) => {
expect(selectCount(store.getState())).toBe(3)
store.dispatch(incrementByAmount(2))
expect(selectCount(store.getState())).toBe(5)
})
})

View File

@ -1,89 +0,0 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import { createAppSlice } from "../../app/createAppSlice"
import type { AppThunk } from "../../app/store"
import { fetchCount } from "./counterAPI"
export interface CounterSliceState {
value: number
status: "idle" | "loading" | "failed"
}
const initialState: CounterSliceState = {
value: 0,
status: "idle",
}
// If you are not using async thunks you can use the standalone `createSlice`.
export const counterSlice = createAppSlice({
name: "counter",
// `createSlice` will infer the state type from the `initialState` argument
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: create => ({
increment: create.reducer(state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
}),
decrement: create.reducer(state => {
state.value -= 1
}),
// Use the `PayloadAction` type to declare the contents of `action.payload`
incrementByAmount: create.reducer(
(state, action: PayloadAction<number>) => {
state.value += action.payload
},
),
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
incrementAsync: create.asyncThunk(
async (amount: number) => {
const response = await fetchCount(amount)
// The value we return becomes the `fulfilled` action payload
return response.data
},
{
pending: state => {
state.status = "loading"
},
fulfilled: (state, action) => {
state.status = "idle"
state.value += action.payload
},
rejected: state => {
state.status = "failed"
},
},
),
}),
// You can define your selectors here. These selectors receive the slice
// state as their first argument.
selectors: {
selectCount: counter => counter.value,
selectStatus: counter => counter.status,
},
})
// Action creators are generated for each case reducer function.
export const { decrement, increment, incrementByAmount, incrementAsync } =
counterSlice.actions
// Selectors returned by `slice.selectors` take the root state as their first argument.
export const { selectCount, selectStatus } = counterSlice.selectors
// We can also write thunks by hand, which may contain both sync and async logic.
// Here's an example of conditionally dispatching actions based on current state.
export const incrementIfOdd =
(amount: number): AppThunk =>
(dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1 || currentValue % 2 === -1) {
dispatch(incrementByAmount(amount))
}
}

View File

@ -1,20 +0,0 @@
.select {
font-size: 25px;
padding: 5px;
padding-top: 2px;
padding-bottom: 2px;
size: 50;
outline: none;
border: 2px solid transparent;
color: rgb(112, 76, 182);
cursor: pointer;
background-color: rgba(112, 76, 182, 0.1);
border-radius: 5px;
transition: all 0.15s;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}

View File

@ -1,59 +0,0 @@
import { useState } from "react"
import styles from "./Quotes.module.css"
import { useGetQuotesQuery } from "./quotesApiSlice"
const options = [5, 10, 20, 30]
export const Quotes = () => {
const [numberOfQuotes, setNumberOfQuotes] = useState(10)
// Using a query hook automatically fetches data and returns query values
const { data, isError, isLoading, isSuccess } =
useGetQuotesQuery(numberOfQuotes)
if (isError) {
return (
<div>
<h1>There was an error!!!</h1>
</div>
)
}
if (isLoading) {
return (
<div>
<h1>Loading...</h1>
</div>
)
}
if (isSuccess) {
return (
<div className={styles.container}>
<h3>Select the Quantity of Quotes to Fetch:</h3>
<select
className={styles.select}
value={numberOfQuotes}
onChange={e => {
setNumberOfQuotes(Number(e.target.value))
}}
>
{options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{data.quotes.map(({ author, quote, id }) => (
<blockquote key={id}>
&ldquo;{quote}&rdquo;
<footer>
<cite>{author}</cite>
</footer>
</blockquote>
))}
</div>
)
}
return null
}

View File

@ -1,38 +0,0 @@
// Need to use the React-specific entry point to import `createApi`
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
interface Quote {
id: number
quote: string
author: string
}
interface QuotesApiResponse {
quotes: Quote[]
total: number
skip: number
limit: number
}
// Define a service using a base URL and expected endpoints
export const quotesApiSlice = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "https://dummyjson.com/quotes" }),
reducerPath: "quotesApi",
// Tag types are used for caching and invalidation.
tagTypes: ["Quotes"],
endpoints: build => ({
// Supply generics for the return type (in this case `QuotesApiResponse`)
// and the expected query argument. If there is no argument, use `void`
// for the argument type instead.
getQuotes: build.query<QuotesApiResponse, number>({
query: (limit = 10) => `?limit=${limit}`,
// `providesTags` determines which 'tag' is attached to the
// cached data returned by the query.
providesTags: (result, error, id) => [{ type: "Quotes", id }],
}),
}),
})
// Hooks are auto-generated by RTK-Query
// Same as `quotesApiSlice.endpoints.getQuotes.useQuery`
export const { useGetQuotesQuery } = quotesApiSlice

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#764ABC"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,14 +1,14 @@
import React from "react" import React from "react";
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client";
import { Provider } from "react-redux" import { Provider } from "react-redux";
import App from "./App" import { store } from "src/redux/store";
import { store } from "./app/store" import App from "src/App";
import "./index.css" import "src/index.css";
const container = document.getElementById("root") const container = document.getElementById("root");
if (container) { if (container) {
const root = createRoot(container) const root = createRoot(container);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
@ -16,9 +16,9 @@ if (container) {
<App /> <App />
</Provider> </Provider>
</React.StrictMode>, </React.StrictMode>,
) );
} else { } else {
throw new Error( throw new Error(
"Root element with ID 'root' was not found in the document. Ensure there is a corresponding HTML element with the ID 'root' in your HTML file.", "Root element with ID 'root' was not found in the document. Ensure there is a corresponding HTML element with the ID 'root' in your HTML file.",
) );
} }

View File

@ -4,9 +4,9 @@
// We disable the ESLint rule here because this is the designated place // We disable the ESLint rule here because this is the designated place
// for importing and re-exporting the typed versions of hooks. // for importing and re-exporting the typed versions of hooks.
/* eslint-disable @typescript-eslint/no-restricted-imports */ /* eslint-disable @typescript-eslint/no-restricted-imports */
import { useDispatch, useSelector } from "react-redux" import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store" import type { AppDispatch, RootState } from "src/redux/store";
// Use throughout your app instead of plain `useDispatch` and `useSelector` // Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>() export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>() export const useAppSelector = useSelector.withTypes<RootState>();

24
src/redux/store.ts Normal file
View File

@ -0,0 +1,24 @@
import { combineSlices, configureStore } from "@reduxjs/toolkit";
// pass in slices to combine into combineSlices()
// `combineSlices` automatically combines the reducers using
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
const rootReducer = combineSlices();
// Infer the `RootState` type from the root reducer
type RootState = ReturnType<typeof rootReducer>;
const store = configureStore({
reducer: rootReducer,
// this is unnecessary, but included for demonstration purposes
// since default middlewares are automatically included
middleware: getDefaultMiddleware => getDefaultMiddleware(),
});
// Infer the type of `store`
type AppStore = typeof store;
// Infer the `AppDispatch` type from the store itself
type AppDispatch = AppStore["dispatch"];
export { store };
export type { RootState, AppStore, AppDispatch };

View File

@ -1 +0,0 @@
import "@testing-library/jest-dom/vitest"

View File

@ -1,65 +0,0 @@
import type { RenderOptions } from "@testing-library/react"
import { render } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { PropsWithChildren, ReactElement } from "react"
import { Provider } from "react-redux"
import type { AppStore, RootState } from "../app/store"
import { makeStore } from "../app/store"
/**
* This type extends the default options for
* React Testing Library's render function. It allows for
* additional configuration such as specifying an initial Redux state and
* a custom store instance.
*/
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
/**
* Defines a specific portion or the entire initial state for the Redux store.
* This is particularly useful for initializing the state in a
* controlled manner during testing, allowing components to be rendered
* with predetermined state conditions.
*/
preloadedState?: Partial<RootState>
/**
* Allows the use of a specific Redux store instance instead of a
* default or global store. This flexibility is beneficial when
* testing components with unique store requirements or when isolating
* tests from a global store state. The custom store should be configured
* to match the structure and middleware of the store used by the application.
*
* @default makeStore(preloadedState)
*/
store?: AppStore
}
/**
* Renders the given React element with Redux Provider and custom store.
* This function is useful for testing components that are connected to the Redux store.
*
* @param ui - The React component or element to render.
* @param extendedRenderOptions - Optional configuration options for rendering. This includes `preloadedState` for initial Redux state and `store` for a specific Redux store instance. Any additional properties are passed to React Testing Library's render function.
* @returns An object containing the Redux store used in the render, User event API for simulating user interactions in tests, and all of React Testing Library's query functions for testing the component.
*/
export const renderWithProviders = (
ui: ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {},
) => {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = makeStore(preloadedState),
...renderOptions
} = extendedRenderOptions
const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)
// Return an object with the store and all of RTL's query functions
return {
store,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions }),
}
}

View File

@ -1,5 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"]
},
"target": "ESNext", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],

View File

@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config" import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@ -13,4 +13,9 @@ export default defineConfig({
setupFiles: "src/setupTests", setupFiles: "src/setupTests",
mockReset: true, mockReset: true,
}, },
}) resolve: {
alias: {
src: "/src",
},
},
});