diff options
Diffstat (limited to 'src/features')
-rw-r--r-- | src/features/counter/Counter.jsx | 67 | ||||
-rw-r--r-- | src/features/counter/Counter.module.css | 78 | ||||
-rw-r--r-- | src/features/counter/counterAPI.js | 6 | ||||
-rw-r--r-- | src/features/counter/counterSlice.js | 73 | ||||
-rw-r--r-- | src/features/counter/counterSlice.spec.js | 33 |
5 files changed, 257 insertions, 0 deletions
diff --git a/src/features/counter/Counter.jsx b/src/features/counter/Counter.jsx new file mode 100644 index 0000000..37d866d --- /dev/null +++ b/src/features/counter/Counter.jsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { + decrement, + increment, + incrementByAmount, + incrementAsync, + incrementIfOdd, + selectCount, +} from "./counterSlice"; +import styles from "./Counter.module.css"; + +export function Counter() { + const count = useSelector(selectCount); + const dispatch = useDispatch(); + 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 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} + 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> + <button + className={styles.button} + onClick={() => dispatch(incrementIfOdd(incrementValue))} + > + Add If Odd + </button> + </div> + </div> + ); +} diff --git a/src/features/counter/Counter.module.css b/src/features/counter/Counter.module.css new file mode 100644 index 0000000..9e70f8e --- /dev/null +++ b/src/features/counter/Counter.module.css @@ -0,0 +1,78 @@ +.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; +} diff --git a/src/features/counter/counterAPI.js b/src/features/counter/counterAPI.js new file mode 100644 index 0000000..cc9b4a4 --- /dev/null +++ b/src/features/counter/counterAPI.js @@ -0,0 +1,6 @@ +// A mock function to mimic making an async request for data +export function fetchCount(amount = 1) { + return new Promise((resolve) => + setTimeout(() => resolve({ data: amount }), 500) + ); +} diff --git a/src/features/counter/counterSlice.js b/src/features/counter/counterSlice.js new file mode 100644 index 0000000..a9441ac --- /dev/null +++ b/src/features/counter/counterSlice.js @@ -0,0 +1,73 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { fetchCount } from "./counterAPI"; + +const initialState = { + value: 0, + status: "idle", +}; + +// 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. +export const incrementAsync = createAsyncThunk( + "counter/fetchCount", + async (amount) => { + const response = await fetchCount(amount); + // The value we return becomes the `fulfilled` action payload + return response.data; + } +); + +export const counterSlice = createSlice({ + name: "counter", + initialState, + // The `reducers` field lets us define reducers and generate associated actions + reducers: { + increment: (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: (state) => { + state.value -= 1; + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action) => { + state.value += action.payload; + }, + }, + // The `extraReducers` field lets the slice handle actions defined elsewhere, + // including actions generated by createAsyncThunk or in other slices. + extraReducers: (builder) => { + builder + .addCase(incrementAsync.pending, (state) => { + state.status = "loading"; + }) + .addCase(incrementAsync.fulfilled, (state, action) => { + state.status = "idle"; + state.value += action.payload; + }); + }, +}); + +export const { increment, decrement, incrementByAmount } = counterSlice.actions; + +// The function below is called a selector and allows us to select a value from +// the state. Selectors can also be defined inline where they're used instead of +// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)` +export const selectCount = (state) => state.counter.value; + +// 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) => (dispatch, getState) => { + const currentValue = selectCount(getState()); + if (currentValue % 2 === 1) { + dispatch(incrementByAmount(amount)); + } +}; + +export default counterSlice.reducer; diff --git a/src/features/counter/counterSlice.spec.js b/src/features/counter/counterSlice.spec.js new file mode 100644 index 0000000..113526b --- /dev/null +++ b/src/features/counter/counterSlice.spec.js @@ -0,0 +1,33 @@ +import counterReducer, { + increment, + decrement, + incrementByAmount, +} from "./counterSlice"; + +describe("counter reducer", () => { + const initialState = { + value: 3, + status: "idle", + }; + it("should handle initial state", () => { + expect(counterReducer(undefined, { type: "unknown" })).toEqual({ + value: 0, + status: "idle", + }); + }); + + it("should handle increment", () => { + const actual = counterReducer(initialState, increment()); + expect(actual.value).toEqual(4); + }); + + it("should handle decrement", () => { + const actual = counterReducer(initialState, decrement()); + expect(actual.value).toEqual(2); + }); + + it("should handle incrementByAmount", () => { + const actual = counterReducer(initialState, incrementByAmount(2)); + expect(actual.value).toEqual(5); + }); +}); |