From 4d403e464d757313b9ca2141dd505c91af66e818 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Fri, 22 Nov 2024 18:44:24 +0100 Subject: [PATCH] Migrate away from redux --- package.json | 7 +- src/App.tsx | 6 +- src/components/InboxScreen.stories.tsx | 32 ++++--- src/components/InboxScreen.tsx | 18 +--- src/components/TaskList.stories.tsx | 126 ++++++------------------- src/components/TaskList.tsx | 33 +++---- src/lib/hooks.ts | 16 ---- src/lib/selectors.ts | 21 ----- src/lib/store.ts | 105 --------------------- src/lib/useTasks.ts | 56 +++++++++++ 10 files changed, 124 insertions(+), 296 deletions(-) delete mode 100644 src/lib/hooks.ts delete mode 100644 src/lib/selectors.ts delete mode 100644 src/lib/store.ts create mode 100644 src/lib/useTasks.ts diff --git a/package.json b/package.json index c70d1075..bb232623 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,13 @@ } }, "dependencies": { - "@reduxjs/toolkit": "^2.0.1", "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-redux": "^9.0.4" + "react-dom": "^18.2.0" }, "devDependencies": { "@chromatic-com/storybook": "^3.2.2", "@storybook/addon-a11y": "^8.5.0-alpha.9", "@storybook/addon-essentials": "^8.5.0-alpha.9", - "@storybook/blocks": "^8.5.0-alpha.9", "@storybook/experimental-addon-test": "^8.5.0-alpha.9", "@storybook/react": "^8.5.0-alpha.9", "@storybook/react-vite": "^8.5.0-alpha.9", @@ -63,9 +60,7 @@ "msw": "^2.3.0", "msw-storybook-addon": "^2.0.3", "playwright": "^1.49.0", - "prop-types": "^15.8.1", "storybook": "^8.5.0-alpha.9", - "ts-migrate": "^0.1.35", "typescript": "^5.6.3", "vite": "^5.2.0", "vitest": "^2.1.5" diff --git a/src/App.tsx b/src/App.tsx index 5273c10b..741e58e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,9 @@ import "./index.css"; -import store from "./lib/store"; -import { Provider } from "react-redux"; import InboxScreen from "./components/InboxScreen"; function App() { return ( - - - + ); } diff --git a/src/components/InboxScreen.stories.tsx b/src/components/InboxScreen.stories.tsx index 47e53f3c..567f3dd1 100644 --- a/src/components/InboxScreen.stories.tsx +++ b/src/components/InboxScreen.stories.tsx @@ -1,24 +1,20 @@ import { Meta, StoryObj } from '@storybook/react' import { HttpResponse, http, delay } from 'msw' -import { Provider } from 'react-redux' import MockDate from 'mockdate' -import store from '../lib/store' import InboxScreen from './InboxScreen' import * as mocks from '../mocks/data' import { userEvent, - waitFor, - within, waitForElementToBeRemoved, + expect, } from '@storybook/test' import { getFormattedDate } from '#utils/date.mock.ts' const meta = { component: InboxScreen, title: 'InboxScreen', - decorators: [(story) => {story()}], } satisfies Meta export default meta; @@ -41,17 +37,27 @@ export const Default: Story = { export const PinnedTasks: Story = { ...Default, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement) + play: async ({ canvas, step }) => { // Waits for the component to transition from the loading state await waitForElementToBeRemoved(await canvas.findByTestId('loading')) - // Waits for the component to be updated based on the store - await waitFor(async () => { - // Simulates pinning the first task - await userEvent.click(canvas.getByLabelText('Pin Learn more about Storybook')) - // Simulates pinning the third task - await userEvent.click(canvas.getByLabelText('Pin Schedule annual health check-up')) + + await step('Ensure tasks are rendered in the initial order', async () => { + const listItems = canvas.getAllByRole("listitem"); + await expect(listItems[0]).toHaveTextContent("Learn more about Storybook"); + await expect(listItems[1]).toHaveTextContent("Go to the gym"); }) + + await step('Pin "Go to the gym" task', async () => { + // Pin Learn more about Storybook and verify it moves to the top + const pinButton = canvas.getByLabelText("Pin Go to the gym"); + await userEvent.click(pinButton); + }); + + await step('Ensure tasks order is changed', async () => { + const updatedListItems = canvas.getAllByRole("listitem"); + await expect(updatedListItems[0]).toHaveTextContent("Go to the gym"); + await expect(updatedListItems[1]).toHaveTextContent("Learn more about Storybook"); + }); }, } diff --git a/src/components/InboxScreen.tsx b/src/components/InboxScreen.tsx index 2afc04a8..ca7914f7 100644 --- a/src/components/InboxScreen.tsx +++ b/src/components/InboxScreen.tsx @@ -1,20 +1,12 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; -import { useDispatch, useSelector } from "../lib/hooks"; -import { fetchTasks } from "../lib/store"; -import { selectTaskbox } from "../lib/selectors"; import TaskList from "./TaskList"; import { getFormattedDate } from "#utils/date"; +import { useTasks } from "#lib/useTasks.ts"; export default function InboxScreen() { - const dispatch = useDispatch(); - // We're retrieving the error field from our updated store - const { error } = useSelector(selectTaskbox); - // The useEffect triggers the data fetching when the component is mounted - useEffect(() => { - dispatch(fetchTasks()); - }, []); - + const { error, tasks, updateTaskState, status } = useTasks(); + const today = useMemo(() => getFormattedDate(new Date()), []); if (error) { @@ -33,7 +25,7 @@ export default function InboxScreen() { - + ); } diff --git a/src/components/TaskList.stories.tsx b/src/components/TaskList.stories.tsx index 98132848..60dfef58 100644 --- a/src/components/TaskList.stories.tsx +++ b/src/components/TaskList.stories.tsx @@ -1,46 +1,17 @@ -import type { ReactElement } from 'react'; import type { Meta, StoryObj } from '@storybook/react' -import { Provider } from "react-redux"; -import { configureStore, createSlice } from "@reduxjs/toolkit"; +import { expect, fn, userEvent } from '@storybook/test'; +import { UseTasksPayload } from '#lib/useTasks.ts'; import TaskList from "./TaskList"; -import { State } from '../lib/store'; -import { Task } from '../types'; import * as mocks from "../mocks/data"; -import { expect, userEvent } from '@storybook/test'; -// A super-simple mock of the state of the store -const defaultTaskboxState: State = { +const defaultTaskboxState: UseTasksPayload = { tasks: mocks.tasks, status: "idle", error: null, + updateTaskState: fn() }; -// A super-simple mock of a redux store -const Mockstore = ({ taskboxState, children }: { taskboxState: State, children: ReactElement }) => ( - { - const { id, newTaskState } = action.payload; - const task = state.tasks.findIndex((task) => task.id === id); - if (task >= 0) { - state.tasks[task].state = newTaskState; - } - }, - }, - }).reducer, - }, - })} - > - {children} - -); - const meta = { component: TaskList, title: "TaskList", @@ -52,85 +23,44 @@ export default meta; type Story = StoryObj export const Default: Story = { - decorators: [ - (story) => {story()}, - ], + args: { + ...defaultTaskboxState, + } }; export const WithPinnedTasks: Story = { - decorators: [ - (story) => { - const pinnedtasks: Task[] = [ - ...defaultTaskboxState.tasks.slice(0, 5), - { id: "6", title: "Task 6 (pinned)", state: "TASK_PINNED" }, - ]; - - return ( - - {story()} - - ); - }, - ], + args: { + ...Default.args, + tasks: [ + ...defaultTaskboxState.tasks.slice(0, 5), + { id: "6", title: "Task 6 (pinned)", state: "TASK_PINNED" }, + ], + } }; export const Loading: Story = { - decorators: [ - (story) => ( - - {story()} - - ), - ], + args: { + ...Default.args, + status: "loading", + } }; export const Empty: Story = { - decorators: [ - (story) => ( - - {story()} - - ), - ], + args: { + ...Default.args, + tasks: [], + } }; export const TestPinBehavior: Story = { ...Default, - play: async ({ canvas, step }) => { - - await step('Ensure tasks are rendered in the initial order', async () => { - const listItems = canvas.getAllByRole("listitem"); - await expect(listItems[0]).toHaveTextContent("Learn more about Storybook"); - await expect(listItems[1]).toHaveTextContent("Go to the gym"); - }) - - await step('Pin "Go to the gym" task', async () => { - // Pin Learn more about Storybook and verify it moves to the top - const pinButton = canvas.getByLabelText("Pin Go to the gym"); - await userEvent.click(pinButton); - }); + play: async({ canvas, args }) => { + await userEvent.click(canvas.getByLabelText('Pin Learn more about Storybook')) + await expect(args.updateTaskState).toHaveBeenCalledWith(mocks.task.id, 'TASK_PINNED') - await step('Ensure tasks order is changed', async () => { - const updatedListItems = canvas.getAllByRole("listitem"); - await expect(updatedListItems[0]).toHaveTextContent("Go to the gym"); - await expect(updatedListItems[1]).toHaveTextContent("Learn more about Storybook"); - }); + await userEvent.click(canvas.getByLabelText('Archive Learn more about Storybook')) + await expect(args.updateTaskState).toHaveBeenCalledWith(mocks.task.id, 'TASK_ARCHIVED') }, // hide the story from autodocs page as it's intended for test purposes only tags: ['!autodocs'] -}; \ No newline at end of file +}; diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx index 29aab7fb..01bb5eee 100644 --- a/src/components/TaskList.tsx +++ b/src/components/TaskList.tsx @@ -1,24 +1,18 @@ -import { useDispatch, useSelector } from "../lib/hooks"; -import { updateTaskState } from "../lib/store"; -import { selectFilteredTasks, selectTaskbox } from "../lib/selectors"; -import Task from "./Task"; +import { UseTasksPayload } from "#lib/useTasks.ts"; +import TaskComponent from "./Task"; -export default function TaskList() { - // We're retrieving our state from the store - const tasks = useSelector(selectFilteredTasks); +type TaskListProps = UseTasksPayload; - const { status } = useSelector(selectTaskbox); +export default function TaskList({ tasks = [], updateTaskState, status }: TaskListProps) { + // Filtered tasks (similar to the previous `selectFilteredTasks`) + const filteredTasks = [ + ...tasks.filter((t) => t.state === "TASK_PINNED"), + ...tasks.filter((t) => t.state !== "TASK_PINNED"), + ].filter((t) => t.state === "TASK_INBOX" || t.state === "TASK_PINNED"); - const dispatch = useDispatch(); + const pinTask = (id: string) => updateTaskState(id, "TASK_PINNED"); - const pinTask = (id: string) => { - // We're dispatching the Pinned event back to our store - dispatch(updateTaskState({ id, newTaskState: "TASK_PINNED" })); - }; - const archiveTask = (id: string) => { - // We're dispatching the Archive event back to our store - dispatch(updateTaskState({ id, newTaskState: "TASK_ARCHIVED" })); - }; + const archiveTask = (id: string) => updateTaskState(id, "TASK_ARCHIVED"); if (status === "loading") { return ( @@ -46,10 +40,11 @@ export default function TaskList() { ); } + return (
    - {tasks.map((task) => ( - ( + pinTask(task)} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts deleted file mode 100644 index 493a9b00..00000000 --- a/src/lib/hooks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - TypedUseSelectorHook, - useDispatch as _useDispatch, - useSelector as _useSelector, -} from 'react-redux' - -import { AppDispatch, RootState } from './store' - -/** - * The reason we expose our own dispatch and selector - * is that we get typescript safety anywhere, which is - * automatically updated as we add more slices to the application. - */ - -export const useDispatch = () => _useDispatch() -export const useSelector: TypedUseSelectorHook = _useSelector diff --git a/src/lib/selectors.ts b/src/lib/selectors.ts deleted file mode 100644 index 1d595444..00000000 --- a/src/lib/selectors.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit' - -import { RootState } from './store' - -export const selectTaskbox = (state: RootState) => state.taskbox - -export const selectTasks = createSelector( - [selectTaskbox], - (state) => state.tasks -) - -export const selectFilteredTasks = createSelector([selectTasks], (state) => { - const tasksInOrder = [ - ...state.filter((t) => t.state === 'TASK_PINNED'), - ...state.filter((t) => t.state !== 'TASK_PINNED'), - ] - const filteredTasks = tasksInOrder.filter( - (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' - ) - return filteredTasks -}) diff --git a/src/lib/store.ts b/src/lib/store.ts deleted file mode 100644 index 0f3f4380..00000000 --- a/src/lib/store.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* A simple redux store/actions/reducer implementation. - * A true app would be more complex and separated into different files. - */ -import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit' -import { Task } from '../types' - -export type State = { - tasks: Task[] - status: 'idle' | 'loading' | 'succeeded' | 'failed' - error: string | null -} - -/* - * The initial state of our store when the app loads. - * Usually, you would fetch this from a server. Let's not worry about that now - */ - -const TaskBoxData: State = { - tasks: [], - status: 'idle', - error: null, -} - -/* - * Creates an asyncThunk to fetch tasks from a remote endpoint. - * You can read more about Redux Toolkit's thunks in the docs: - * https://redux-toolkit.js.org/api/createAsyncThunk - */ -export const fetchTasks = createAsyncThunk('todos/fetchTodos', async () => { - const response = await fetch( - 'https://jsonplaceholder.typicode.com/todos?userId=1' - ) - const data: Array<{ id: number; title: string; completed: boolean }> = - await response.json() - const result = data.map((task) => ({ - id: `${task.id}`, - title: task.title, - state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX', - })) - return result -}) - -/* - * The store is created here. - * You can read more about Redux Toolkit's slices in the docs: - * https://redux-toolkit.js.org/api/createSlice - */ -const TasksSlice = createSlice({ - name: 'taskbox', - initialState: TaskBoxData, - reducers: { - updateTaskState: ( - state, - action: { payload: { id: string; newTaskState: Task['state'] } } - ) => { - const { id, newTaskState } = action.payload - const task = state.tasks.findIndex((task) => task.id === id) - if (task >= 0) { - state.tasks[task].state = newTaskState - } - }, - }, - /* - * Extends the reducer for the async actions - * You can read more about it at https://redux-toolkit.js.org/api/createAsyncThunk - */ - extraReducers(builder) { - builder - .addCase(fetchTasks.pending, (state) => { - state.status = 'loading' - state.error = null - state.tasks = [] - }) - .addCase(fetchTasks.fulfilled, (state, action) => { - state.status = 'succeeded' - state.error = null - // Add any fetched tasks to the array - state.tasks = action.payload as Task[] - }) - .addCase(fetchTasks.rejected, (state) => { - state.status = 'failed' - state.error = 'Something went wrong' - state.tasks = [] - }) - }, -}) - -// The actions contained in the slice are exported for usage in our components -export const { updateTaskState } = TasksSlice.actions - -/* - * Our app's store configuration goes here. - * Read more about Redux's configureStore in the docs: - * https://redux-toolkit.js.org/api/configureStore - */ -const store = configureStore({ - reducer: { - taskbox: TasksSlice.reducer, - }, -}) - -export default store - -export type AppDispatch = typeof store.dispatch -export type RootState = ReturnType diff --git a/src/lib/useTasks.ts b/src/lib/useTasks.ts new file mode 100644 index 00000000..846886f2 --- /dev/null +++ b/src/lib/useTasks.ts @@ -0,0 +1,56 @@ +import { useState, useEffect } from 'react' +import { Task } from '../types' + +export type UseTasksPayload = { + tasks: Task[] + status: 'idle' | 'loading' | 'succeeded' | 'failed' + error?: string | null + updateTaskState: (id: string, newTaskState: Task['state']) => void +} + +export function useTasks(): UseTasksPayload { + const [tasks, setTasks] = useState([]) + const [status, setStatus] = useState< + 'idle' | 'loading' | 'succeeded' | 'failed' + >('idle') + const [error, setError] = useState(null) + + useEffect(() => { + const fetchTasks = async () => { + setStatus('loading') + setError(null) + + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/todos?userId=1' + ) + const data: Array<{ id: number; title: string; completed: boolean }> = + await response.json() + + const formattedTasks: Task[] = data.map((task) => ({ + id: `${task.id}`, + title: task.title, + state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX', + })) + + setTasks(formattedTasks) + setStatus('succeeded') + } catch (e) { + setError('Something went wrong') + setStatus('failed') + } + } + + fetchTasks() + }, []) + + const updateTaskState = (id: string, newTaskState: Task['state']) => { + setTasks((currentTasks) => + currentTasks.map((task) => + task.id === id ? { ...task, state: newTaskState } : task + ) + ) + } + + return { tasks, status, error, updateTaskState } +}