This step-by-step guide will walk you through building a feature-rich Task Management App to enhance productivity and organization. The app includes essential functionalities for creating, updating, and managing tasks efficiently.
While developing this application, let’s examine it in 3 parts.
I use MSW and MSW Data infrastructure for the backend. I use the Faker library to create Random Data. I have previously made blog posts and examples on this subject.
First and foremost, I utilize MSW Data in the DB section to generate random data appropriate for my model.
import { faker } from "@faker-js/faker";
import { factory, primaryKey } from "@mswjs/data";
export const db = factory({
task: {
id: primaryKey(faker.string.uuid),
title: String,
isDone: Boolean,
},
});
for (let i = 0; i < 10; i++) {
db.task.create({
title: faker.lorem.words(),
isDone: faker.datatype.boolean(),
});
}
Using MSW (Mock Service Worker) this DB offers REST API to the outside…
GET (`${restApiUri}/tasks -> GET TASK LIST
GET (`${restApiUri}/tasks/:id` -> ID TASK FREE
POST (`${restApiUri}/tasks` -> CREATES NEW TASK
PATCH. (`${restApiUri}/tasks` -> UPDATES TASK LIST
PUT (`${restApiUri}/tasks/:id` -> UPDATE RELATED TASK
DELETE (`${restApiUri}/tasks/:id -> DELETES RELATED TASK
You can also develop a GraphQL version of the same service. This is also quite easy when using MSWData
...db.task.toHandlers('graphql', graphqlApiUri),
I have shared a couple of blog posts on this topic specific to the React section. There are links to other blog posts in this one.
Let’s focus on the place in the blue frame in the section below. In the articles linked above, I explained how I interacted with RestAPI with React Query, Container — Component structure patterns via Axios.
Important concerns here:
For all of this interaction, you must invalidate the data in the Client Cache and initiate the retrieval of data from the Server anew. For this procedure, use the ReactQuery command shown below.
queryClient.invalidateQueries("listTasks");
There are many issues such as showing the incoming data to the user, providing user interaction on these screens, passing data between components, interacting with the backend.
First of all, in order to get rid of the Props Drilling method in Data Transfer for Frontend Components and to make components more independent, you should be able to pass data between components more easily, but you should not multiplex data.
For this, if you use the data as much as possible in the parts you get from the main source, it will be useful for you.
(Note: RSC React Server Component, Remix Routing, Next.JS Routing components, you can think of the data obtained on the server as filling data on those components immediately, it actually serves the same purpose)
In this example we are using Zustand to Interchange User Data in the Client, you can use Jotai, Signal, Redux etc. instead.
There are 2 Client Stores below. One is selectedRowKeys, which the user uses to perform operations on more than one Task by selecting a checkbox, it performs the deletion operation through it
import { create } from "zustand";
export const useTaskListTableStore = create((set) => ({
selectedRowKeys: [],
setSelectedRowKeys: (selectedRowKeys) => {
console.log("selectedRowKeys", selectedRowKeys);
set({ selectedRowKeys });
},
}));
Another one is that when the user creates a new Task, the Input value entered needs to be moved.
export const useTaskInputTextStore = create((set) => ({
taskInputText: "",
setTaskInputText: (taskInputText) => {
set({ taskInputText });
},
}));
You can focus on both Client Data and Modifying this Client Data by using methods from any component. For example;
const { setTaskInputText, taskInputText } = useTaskInputTextStore();
There are many methods of server communication;
You can produce many methods similar to this. here I preferred ReactQuery and Axios.
function TaskListContainer() {
const {isLoading, error, data} = useQuery(['listTasks'], () => AxiosManager.get('/tasks').then((res) => res.data));
if (isLoading) return 'Loading...';
if (error) return 'An error has occurred: ' + error.message;
return (
<div>
<TaskListTable data={modifiedData} rowSelection={rowSelection} />
</div>
);
const TaskDeleteButtonContainer = (props) => {
const mutation = useMutation({
mutationFn: (taskId) => {
return AxiosManager.delete(`/tasks/${taskId}`)
.then((res) => {
console.log(res);
queryClient.invalidateQueries("listTasks");
})
.catch((err) => {
console.log(err);
});
},
});
return (
<div style={{ display: "flex" }}>
{mutation.isLoading ? (
"Removing task..."
) : (
<>
<TaskDeleteButton
handleDeleteTask={() => {
mutation.mutate(props.task.id);
}}
/>
</>
)}
</div>
);
};
In this section we are actually using 3 methods of ReactQuery.
useQuery, useMutation, invalidateQueries;
You can basically do the whole application with these 3 methods. Sometimes ReactQuery can access and process the cache data via extra queryClient to access the cache data.
const allTasks = queryClient.getQueryData(["listTasks"]) ?? [];
In this section, I used the Ant Design component library, which includes
I need a table (https://ant.design/components/table) I need to define Actions in this table and I need to be able to give these Actions their own Actions
const TaskTableActions = (props) => {
return (
<Space size="middle">
<TaskUpdateCheckBoxContainer task={props.data} />
<TaskDeleteButtonContainer task={props.data} />
</Space>
);
};
const columns = [
{
title: "Task Name",
dataIndex: "title",
key: "title",
render: (title, record) => (
<span style={{ textDecoration: record.isDone ? "line-through" : "none" }}>
{title}
</span>
),
},
{
title: "Actions",
key: "action",
render: (_, record) => <TaskTableActions data={record} />,
},
];
const TaskListTable = ({ data, rowSelection }) => {
return (
<Table
rowSelection={rowSelection}
dataSource={data}
columns={columns}
bordered
/>
);
};
Since the table has automatic pagination support, my job was easy. But in order for some of the elements on the left to be selected, you need to give the code of the part of the RowSelection interface to the Ant Table that you realize this.
The important thing in this section is that I am storing the selectedRowKeys in the Zustand Store. This way, when other components and Actions want to access it, they can pull it from the ZustandStore as they want. When you want to delete one or more tasks, we can access these indexes through ZustandStore and access the rest through ReactQuery Cache.
Actually, all we do is update the screen by looking at the state (disabled={hasArrayElement(selectedRowKeys) === false})
<TaskDeleteBulkButton
disabled={hasArrayElement(selectedRowKeys) === false}
selectedRowKeys={selectedRowKeys}
handleDeleteBulkTask={(deleteTasksIds) => {
mutation.mutate({ idsToDelete: deleteTasksIds });
}}
/>
The 2nd example is that the task crossing out is bound to the textDecoration record.isDone data.
{
title: 'Task Name',
dataIndex: 'title',
key: 'title',
render: (title, record) => <span style={{textDecoration: record.isDone ? 'line-through' : 'none'}}>{title}</span>,
},
Another important issue is to ask for approval for these operations by displaying some confirmation screens to the users, especially in deletion operations, Ant Design Popconfirm components show these screens and you can continue your onConfirm Callback operation.
const TaskDeleteBulkButton = (props) => {
const allTasks = queryClient.getQueryData(["listTasks"]) ?? [];
const selectedTasks = props.selectedRowKeys.map((el) => allTasks[el]);
return (
<Popconfirm
title="Delete selected tasks"
description={
<div>
<p>Are you sure to delete these task?</p>
<ul>
{selectedTasks.map((task) => (
<li>{task?.title}</li>
))}
</ul>
</div>
}
onConfirm={() => {
props.handleDeleteBulkTask(selectedTasks.map((task) => task.id));
}}
onCancel={() => {
console.log("cancel");
}}
okText="Yes"
cancelText="No"
>
<Button type="primary" disabled={props.disabled} danger>
Delete Selections
</Button>
</Popconfirm>
);
};