learn react ui logoLearnReactUI
How to Build a Task Management App Step by Step

How to Build a Task Management App Step by Step?

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.

[Demo]

Use Case

  • Users will be able to see the task list.
  • Users will be able to specify new tasks.
  • The user will be able to update the completion status of the current task.
  • The user will be able to select and delete one or more of their tasks.

Todo App Demo

While developing this application, let’s examine it in 3 parts.

  • Backend Model and Services
  • Frontend / Backend Interaction
  • Frontend Application Part

Backend Model and Services

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.

  • React Digital Garden and MSW (Mock Service Worker)
  • Task Management Application CRUD operations with Rest API

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…

Todo App Concept

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),

Frontend / Backend Interaction

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.

  • Task Management Application CRUD operations with Rest API
  • React Query (Rest|GraphQL API)

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.

Server Sync Arch

Important concerns here:

  • Trigger Task List Invocation when the first component is mounted on the page.
  • When you hit the button to create a new task, the Backend obtains the task list based on the incoming message.
  • When a task or tasks are removed from the Task List, the Task List must be updated again.

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");

App SS

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)

  • Via Routing
  • Via Client State Management
  • Through Server State Management

Client State Management

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();

Server State Management

There are many methods of server communication;

  • Apollo Client (GraphQL)
  • React Query (Fetch, Axios, GraphQL-Request,)
  • RTK Query
  • SWR
  • Your own Server State Solution, (Redux Toolkit, Fetch, etc…)

You can produce many methods similar to this. here I preferred ReactQuery and Axios.

RQuery1

For Query

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>
  );

For Mutation

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"]) ?? [];

Frontend Component Parts

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.

  • Another important issue is to change the visualizations in UI interactions. For example, in the image below, how the buttons that are Disabled become Enabled, or in the image on the right, how the Completed Task is crossed out.

RQuery2

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>
  );
};