Building the Task Detail page

Learn how to build the Task Detail page.

The Task Detail page is the page that shows the details of a specific task. It is a child page of the Tasks page.

As you may know, we can define a dynamic path in Remix using the convention $param denoted by the $1 prefix. For example, we can define a dynamic path for the Tasks page by creating a file called _app.tasks.$task.tsx. This will create a dynamic path for the Tasks page that will be available at /tasks/:task.

Building the Loader

First - we need to define the loader function for the Task Detail page. The loader function is responsible for fetching the data that will be used by the page.

In this case - we fetch the task data by using the getTask query. This query is defined in the ~/lib/tasks/queries module.

As you can see below, we're able to access the task param by using the args.params.task property. This is possible because we defined the dynamic path as _app.tasks.$task.tsx.

export const meta: MetaFunction = ({ data }) => { return [ { title: data.task.name, }, ]; }; export async function loader(args: LoaderArgs) { const client = getSupabaseServerClient(args.request); const taskId = args.params.task; const { data: task, error } = await getTask(client, Number(taskId)); if (!task || error) { return throwNotFoundException(); } return json({ task, }); }

Don't worry about copying the code above, we will show the full code for the Task Detail page later in this step.

Building the Action

From this page - we also allow users to update the task data. To do that, we define an action function that will be responsible for updating the task data.

export async function action(args: ActionArgs) { const client = getSupabaseServerClient(args.request); const body = await args.request.json(); switch (args.request.method) { case 'PUT': await updateTask(client, body); return json({ success: true }); } return throwNotFoundException(); }

Building the Task Detail Container

Before we build the page, we define a client component named TaskItemContainer that will be used by the page.

This component will be responsible for rendering the form that will be used to update the task - and also for handling the form submission using the useFetcher hook.

The useFetcher hook is a hook that is provided by Remix. When called, it will submit the action function defined above.

app/dashboard/[organization]/tasks/components/TaskItemContainer.tsx
function TaskItemContainer({ task, }: React.PropsWithChildren<{ task: Task; }>) { const fetcher = useFetcher(); const isSubmitting = fetcher.state === 'submitting'; const onUpdate: FormEventHandler<HTMLFormElement> = useCallback( (e) => { e.preventDefault(); const data = new FormData(e.currentTarget); const name = data.get('name') as string; const description = data.get('description') as string; fetcher.submit( { name, description, }, { method: 'PUT', encType: 'application/json', }, ); }, [fetcher], ); return ( <form onSubmit={onUpdate}> <div className={'flex flex-col space-y-4 max-w-xl'}> <Heading type={2}>{task.name}</Heading> <TextField.Label> Name <TextField.Input required name={'name'} defaultValue={task.name} /> </TextField.Label> <Label> Description <Textarea className={'h-32'} name={'description'} defaultValue={task.description} /> </Label> <div className={'flex space-x-2 justify-between'}> <Button href={'../tasks'} color={'transparent'}> <span className={'flex space-x-2 items-center'}> <ChevronLeftIcon className={'w-4'} /> <span>Back to Tasks</span> </span> </Button> <Button loading={isSubmitting}>Update Task</Button> </div> </div> </form> ); }

Building the Task Detail page

Now that we have the TaskItemContainer component, we can build the Task Detail page.

Below is the full code for the Task Detail page.

app/dashboard/[organization]/tasks/[task]/page.tsx
import type { MetaFunction } from '@remix-run/react'; import { useFetcher, useLoaderData } from '@remix-run/react'; import type { ActionArgs } from '@remix-run/node'; import { json } from '@remix-run/node'; import type { LoaderArgs } from '@remix-run/server-runtime'; import type { FormEventHandler } from 'react'; import React, { useCallback } from 'react'; import { ChevronLeftIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'; import Heading from '~/core/ui/Heading'; import Button from '~/core/ui/Button'; import { throwNotFoundException } from '~/core/http-exceptions'; import getSupabaseServerClient from '~/core/supabase/server-client'; import AppHeader from '~/components/AppHeader'; import AppContainer from '~/components/AppContainer'; import { getTask } from '~/lib/tasks/queries'; import type Task from '~/lib/tasks/types/task'; import Label from '~/core/ui/Label'; import Textarea from '~/core/ui/Textarea'; import TextField from '~/core/ui/TextField'; import { updateTask } from '~/lib/tasks/mutations'; export const meta: MetaFunction = ({ data }) => { return [ { title: data.task.name, }, ]; }; export async function loader(args: LoaderArgs) { const client = getSupabaseServerClient(args.request); const taskId = args.params.task; const { data: task, error } = await getTask(client, Number(taskId)); if (!task || error) { return throwNotFoundException(); } return json({ task, }); } const TaskPage = () => { const data = useLoaderData<typeof loader>(); const task = data.task as Task; return ( <> <AppHeader> <TaskPageHeading /> </AppHeader> <AppContainer> <TaskItemContainer task={task} /> </AppContainer> </> ); }; function TaskPageHeading() { return ( <div className={'flex items-center space-x-6'}> <Heading type={4}> <span>Task</span> </Heading> <Button size={'small'} color={'transparent'} href={'/tasks'}> <ArrowLeftIcon className={'mr-2 h-4'} /> Back to Tasks </Button> </div> ); } export default TaskPage; function TaskItemContainer({ task, }: React.PropsWithChildren<{ task: Task; }>) { const fetcher = useFetcher(); const isSubmitting = fetcher.state === 'submitting'; const onUpdate: FormEventHandler<HTMLFormElement> = useCallback( (e) => { e.preventDefault(); const data = new FormData(e.currentTarget); const name = data.get('name') as string; const description = data.get('description') as string; fetcher.submit( { name, description, }, { method: 'PUT', encType: 'application/json', }, ); }, [fetcher], ); return ( <form onSubmit={onUpdate}> <div className={'flex flex-col space-y-4 max-w-xl'}> <Heading type={2}>{task.name}</Heading> <TextField.Label> Name <TextField.Input required name={'name'} defaultValue={task.name} /> </TextField.Label> <Label> Description <Textarea className={'h-32'} name={'description'} defaultValue={task.description} /> </Label> <div className={'flex space-x-2 justify-between'}> <Button href={'../tasks'} color={'transparent'}> <span className={'flex space-x-2 items-center'}> <ChevronLeftIcon className={'w-4'} /> <span>Back to Tasks</span> </span> </Button> <Button loading={isSubmitting}>Update Task</Button> </div> </div> </form> ); } export async function action(args: ActionArgs) { const client = getSupabaseServerClient(args.request); const body = await args.request.json(); switch (args.request.method) { case 'PUT': await updateTask(client, body); return json({ success: true }); } return throwNotFoundException(); }

Perfect, our Tasks App is now complete! 🎉

In the next steps, we take a look at some things you should now while keeping working on your app.


Subscribe to our Newsletter
Get the latest updates about React, Remix, Next.js, Firebase, Supabase and Tailwind CSS