aboutsummaryrefslogtreecommitdiff
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/counter/Counter.jsx67
-rw-r--r--src/features/counter/Counter.module.css78
-rw-r--r--src/features/counter/counterAPI.js6
-rw-r--r--src/features/counter/counterSlice.js73
-rw-r--r--src/features/counter/counterSlice.spec.js33
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);
+ });
+});