Part 4 — Fetching Data from the Server with react-query and Creating the Post Detail Page

Loi Le
13 min readOct 19, 2023

--

This is a part of the Tutorial to build a blog platform with Elixir Phoenix and Next.js that help you to develop a web application from scratch using modern technologies such as Elixir Phoenix, Next.js, and more.

Index | < Pre | Next >

In the previous part, we built the home page using hard-coded data. Now, we will continue with our Front end project development. We will make the posts list dynamic by fetching the real data from the server. We will also build the Post Detail page. To do that, we will use React-Query and axios libraries to manage the data fetching process.

React-Query is the famous tool in React ecosystem that not only fetching but also caching, synchronizing and updating server data. In other words, this is great tool to help of manage server state. axios is other famous tool to handle http request to server.

Lets install them:

yarn add react-query axios

To use react-query, we have to create src/app/contexts/QueryContext.tsx

"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "react-query";

export interface QueryContextProps {
children: React.ReactNode;
}

export default function QueryContext({ children }: QueryContextProps) {
const [queryClient] = useState(() => new QueryClient());

return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

and apply it in the src/app/layout.tsx

import QueryContext from "./contexts/QueryContext";
import "./globals.css";
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<QueryContext>
<div className="min-h-full">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex items-center">
<div className=" text-lg md:text-3xl font-semibold">
Lani Blog
</div>
</div>
</div>
</div>
</header>
{children}
</div>
</QueryContext>
</body>
</html>
);
}

We will wrap the fetching logic inside a hook. That way, we can isolate the fetching logic and can replace these tools easier if needed in future. Lets create src/app/hooks/useQueryPostList.ts

import axios from "axios";
import { useQuery } from "react-query";

export const QUERY_MY_POSTS = "QUERY_MY_POSTS";

export interface PostDto {
id: string;
title: string;
description: string;
content: string;
created_at?: string;
}

export interface Post {
id: string;
title: string;
description: string;
content: string;
createdAt?: Date;
}

const axiosInstance = axios.create({
baseURL: 'http://localhost:4000',
});

const queryPostList: () => Promise<Post[]> = async () => {
const response = await axiosInstance.get<{
data: PostDto[];
}>("/api/posts");
return response.data?.data?.map((postDto) => ({
...postDto,
createdAt: postDto?.created_at ? new Date(postDto?.created_at) : undefined,
}));
};

const useQueryPostList = (initialData?: Post[]) => {
return useQuery([QUERY_MY_POSTS], queryPostList, {
initialData,
});
};

export default useQueryPostList;

we define the type for PostDto as the expected data type for server data and Post for data that we will use in our application.

To use the hook useQuery of react-query, we have to provide the unique key QUERY_MY_POSTS and the query function queryPostList . The consumer can also pass the initialData if needed.

The queryPostList function simply call the our server endpoint: /api/posts and map the response to the Post type data.

Now use it from our page: src/app/posts/page.tsx

"use client";

import useQueryPostList from "../hooks/useQueryPostList";
import EmptyMessage from "./EmptyMessage";
import Introduction from "./Introduction";
import PostCard from "./PostCard";

export default function PostList() {
const { data: postList, isLoading, isError } = useQueryPostList();

if (!postList) return null;

return (
<main>
<div className="bg-white py-24">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<Introduction />
<div className="mx-auto max-w-7xl border-t border-gray-200 pt-10 mt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none">
{postList.length === 0 ? (
<EmptyMessage />
) : (
<div className="grid grid-cols-1 gap-x-8 gap-y-16 w-full lg:grid-cols-3">
{postList.map((post) => (
<PostCard key={post.id} {...post} />
))}
</div>
)}
</div>
</div>
</div>
</main>
);
}

Firstly, we have to add “use client”; to the top of file to tell Next.js that this component will be render at client side not server side so that we can make the the server request to our server API.

Then, we remove the hard coding list and replace with our useQueryPostList hooks. We also display nothing if the postList data is null

Access the page again and you will see the result:

It’s amazing. However, you can see that it delay a little bit before the data is display. The reason is if the data is not read we display nothing (return null). And in the loading time, the data can not be ready. Its not good for user experience. we need to so loading state when the data is fetching. We completely can do that with the isLoading from react-query . On the other hand, we also need to handle the error if the fetching process is fail.

First create the Loading and ErrorMessage components to display loading state and default error message for the app. We can easily predict that these component can be used for many places.

Now, we can create Loading component first: src/app/components/atoms/Loading/index.tsx

const Loading: React.FC = () => {
return (
<div className="w-full flex justify-center">
<svg
className="animate-spin h-6 w-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx={12}
cy={12}
r={10}
stroke="currentColor"
strokeWidth={4}
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
};

export default Loading;

Then, create ErrorMessage component: src/app/components/atoms/ErrorMessage/index.tsx

interface ErrorMessageProps {
message?: string;
}

const ErrorMessage: React.FC<ErrorMessageProps> = ({ message = "Something when wrong" }) => {
return <div className="mt-8 text-red-600 text-lg text-center">{message}</div>;
};

export default ErrorMessage;

If we do not provide the error message, the default will be shown is Something when wrong

Now we can update our page src/app/posts/page.tsx

"use client";

import ErrorMessage from "../components/atoms/ErrorMessage";
import Loading from "../components/atoms/Loading";
import useQueryPostList from "../hooks/useQueryPostList";
import EmptyMessage from "./EmptyMessage";
import Introduction from "./Introduction";
import PostCard from "./PostCard";

export default function PostList() {
const { data: postList, isLoading, isError } = useQueryPostList();

if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);

if (isError || !postList) return <ErrorMessage />;

return (
<main>
<div className="bg-white py-24">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<Introduction />
<div className="mx-auto max-w-7xl border-t border-gray-200 pt-10 mt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none">
{postList.length === 0 ? (
<EmptyMessage />
) : (
<div className="grid grid-cols-1 gap-x-8 gap-y-16 w-full lg:grid-cols-3">
{postList.map((post) => (
<PostCard key={post.id} {...post} />
))}
</div>
)}
</div>
</div>
</div>
</main>
);
}

Now, try to access the page again and you can see the loading state before the data is shown.

Its cool. we can fetch data and display if our application and also handled loading as well as error state.

However, because of “use client”; statement, this page is currently client side rendering, and the data fetching is doing from client side. Its not good for SEO and we need to support server side rendering for this page. I will help you now.

Server Side Rendering

Fist, you need to rename src/app/posts/page.tsx to src/app/posts/PostList/index.tsx and update the content as below:

"use client";

import ErrorMessage from "../../components/atoms/ErrorMessage";
import Loading from "../../components/atoms/Loading";
import useQueryPostList, { Post } from "../../hooks/useQueryPostList";
import EmptyMessage from "../EmptyMessage";
import Introduction from "../Introduction";
import PostCard from "../PostCard";

interface PostListProps {
posts: Post[];
}

const PostList: React.FC<PostListProps> = ({ posts }) => {
const { data: postList, isLoading, isError } = useQueryPostList(posts);

if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);

if (isError || !postList) return <ErrorMessage />;

return (
<main>
<div className="bg-white py-24">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<Introduction />
<div className="mx-auto max-w-7xl border-t border-gray-200 pt-10 mt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none">
{postList.length === 0 ? (
<EmptyMessage />
) : (
<div className="grid grid-cols-1 gap-x-8 gap-y-16 w-full lg:grid-cols-3">
{postList.map((post) => (
<PostCard key={post.id} {...post} />
))}
</div>
)}
</div>
</div>
</div>
</main>
);
};

export default PostList;

We update the import reference, then add default data as the parameters for the component and pass to useQueryPostList. This way, this component can be rendered in both client and server. For server side, we need to fetch data from server and pass to this component as default data. Lets try it by update the file src/app/posts/page.tsx

import PostList from "./PostList";

const getData = async () => {
const res = await fetch("http://localhost:4000/api/posts");

if (!res.ok) {
return [];
}

return res.json();
};

const Home = async () => {
const response: any = await getData();

return <PostList posts={response.data} />;
};

export default Home;

We use the getData function to fetch the list of post from server, if the fetching is fail we response empty array then pass the response as the default params for the PostList component.

Access the page again you will see the result as the same before:

To make sure our data was rendered from server, you can view the page source from browser, for example:

  • Chrome, Microsoft Edge: right click and select View Page Source
  • Safari: right click and select Show Page Source

Refactor useQueryPostList.ts

Lets review again about our src/app/hooks/useQueryPostList.ts

First, you can find the the type Post also used in src/app/posts/PostList/index.tsx This is the signal that we place this type in the hook is the wrong place. Think more about this, The Post and PostDto type or the mapping logic from PostDto to Post is belong to the domain logic, they can be use in may place sin project. So, I suggest that we should group them in the model folder. Lets create src/app/models/posts/types.ts :

export interface PostDto {
id: string;
title: string;
description: string;
content: string;
created_at?: string;
}

export interface Post {
id: string;
title: string;
description: string;
content: string;
createdAt?: Date;
}

Then we will move the mapping logic to this model. Lets create src/app/models/posts/index.ts

import { Post, PostDto } from "./types";

export const toModel = (postDto: PostDto): Post => ({
...postDto,
createdAt: postDto?.created_at ? new Date(postDto?.created_at) : undefined,
});

Next, the axiosInstance, we can easily predict that this can be use for every server communication, so to avoid to duplicate it we should move it to src/app/axios.ts

import axios from "axios";

const axiosInstance = axios.create({
baseURL: 'http://localhost:4000',
});

export default axiosInstance;

However, hard coding http://localhost:4000 is not a good option, we should get it from environment variables. Lets create .env.local

NEXT_PUBLIC_API_URL=http://localhost:4000

The prefix NEXT_PUBLIC_ help you to access for both client and server

Then update our src/app/axios.ts

import axios from "axios";

const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});

export default axiosInstance;

Now we can update our src/app/hooks/useQueryPostList.ts

import { useQuery } from "react-query";
import axiosInstance from "../axios";
import { Post, PostDto } from "../models/posts/types";
import { toModel } from "../models/posts";

export const QUERY_MY_POSTS = "QUERY_MY_POSTS";

const queryPostList: () => Promise<Post[]> = async () => {
const response = await axiosInstance.get<{
data: PostDto[];
}>("/api/posts");

return response.data?.data?.map((postDto) => toModel(postDto));
};

const useQueryPostList = (initialData?: Post[]) => {
return useQuery([QUERY_MY_POSTS], queryPostList, {
initialData,
});
};

export default useQueryPostList;

we used the axiosInstance, and Post, PostDto type as well as toModel mapping function.

Then update the Post import from src/app/posts/PostList/index.tsx

"use client";

import { Post } from "@/app/models/posts/types";
import ErrorMessage from "../../components/atoms/ErrorMessage";
import Loading from "../../components/atoms/Loading";
import useQueryPostList from "../../hooks/useQueryPostList";
import EmptyMessage from "../EmptyMessage";
import Introduction from "../Introduction";
import PostCard from "../PostCard";

interface PostListProps {
posts: Post[];
}

const PostList: React.FC<PostListProps> = ({ posts }) => {
const { data: postList, isLoading, isError } = useQueryPostList(posts);

if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);

if (isError || !postList) return <ErrorMessage />;

return (
<main>
<div className="bg-white py-24">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<Introduction />
<div className="mx-auto max-w-7xl border-t border-gray-200 pt-10 mt-10 sm:mt-16 sm:pt-16 lg:mx-0 lg:max-w-none">
{postList.length === 0 ? (
<EmptyMessage />
) : (
<div className="grid grid-cols-1 gap-x-8 gap-y-16 w-full lg:grid-cols-3">
{postList.map((post) => (
<PostCard key={post.id} {...post} />
))}
</div>
)}
</div>
</div>
</div>
</main>
);
};

export default PostList;

and update the base url for server call as well as the mapping in src/app/posts/page.tsx

import { toModel } from "../models/posts";
import { PostDto } from "../models/posts/types";
import PostList from "./PostList";

const getData = async () => {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/posts` as string);

if (!res.ok) {
return [];
}

return res.json();
};

const Home = async () => {
const response: any = await getData();

return (
<PostList
posts={response.data?.map((postDto: PostDto) => toModel(postDto))}
/>
);
};

export default Home;

Implement View Post Detail feature

We can implement with the similar way with the Post List feature

First, create src/app/hooks/useQueryPostDetail.ts

import { useQuery } from "react-query";
import axios from "../axios";
import { Post, PostDto } from "../models/posts/types";
import { toModel } from "../models/posts";

export const QUERY_POST_DETAIL = "QUERY_POST_DETAIL";

export const queryPostDetail = async ({ queryKey }: { queryKey: any[] }) => {
const [_key, { postId }] = queryKey;

const response = await axios.get<{
data: PostDto;
}>(`/api/posts/${postId}`);

return toModel(response.data?.data);
};

const useQueryPostDetail = (postId: string, initialData?: Post) => {
return useQuery([QUERY_POST_DETAIL, { postId }], queryPostDetail, {
initialData,
});
};

export default useQueryPostDetail;

Again, we used react-query and axios to fetch post detail, the required param for this hook is the postId. we also reused Post, PostDto type and toModel mapping function.

To display the post content in markdown format, we need to install a new package by running this command:

$ yarn add react-markdown remark-gfm @uiw/react-md-editor

We also want to have a nice typography, so we will use the Tailwind CSS Typography plugin. To install it, run this command:

$ yarn add @tailwindcss/typography --dev

Then, we need to update the file tailwind.config.ts to add @tailwindcss/typography as one of the plugins

import type { Config } from 'tailwindcss'

const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [require("@tailwindcss/typography")],
}

export default config

Next, we can create the file src/app/posts/[id]/PostDetail/index.tsx

"use client";

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

import useQueryPostDetail from "@/app/hooks/useQueryPostDetail";
import Loading from "@/app/components/atoms/Loading";
import ErrorMessage from "@/app/components/atoms/ErrorMessage";
import { Post } from "@/app/models/posts/types";

interface PostDetailProps extends Post {}

const PostDetail: React.FC<PostDetailProps> = (post) => {
const {
data: postDetail,
isLoading,
isError,
} = useQueryPostDetail(post.id, post);

const { title, content } = postDetail || {};

if (isLoading)
return (
<div className="my-24">
<Loading />
</div>
);

if (isError) return <ErrorMessage />;

return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-7xl"></div>
<div className="mt-6 sm:mx-auto sm:w-full sm:max-w-7xl px-8">
<div className="prose w-full min-w-full max-w-full">
<h1 className="mt-10 mb-8 text-center text-3xl font-bold leading-9 tracking-tight text-gray-900">
{title}
</h1>
{content && (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
)}
</div>
</div>
</div>
);
};

export default PostDetail;

The [id] folder help to build a dynamic url that we can get the id number later. We also used the prose class that comes with Tailwind CSS Typography.

About out PostDetail component, it use useQueryPostDetail hook to fetch post detail data, used two new packages: ReactMarkdown and remarkGfm to display the content as the markdown format. We also handled loading and error state.

Finally, please create src/app/posts/[id]/page.tsx

import PostDetail from "./PostDetail";

async function getData(id: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/posts/${id}`);

if (!res.ok) {
return [];
}

return res.json();
}

interface PostDetailProps {
params: {
id: string;
};
}

const PostDetailPage: React.FC<PostDetailProps> = async ({ params }) => {
const { id } = params || {};

const response: any = await getData(id);

return <PostDetail {...response.data} />;
};

export default PostDetailPage;

We got the query from params because the [id] folder help us to create the dynamic route. we pass this id to getData function to fetch data from server and then pass to the PostDetail component as the default data.

The last thing is we need to link the post detail page from the post list page. Please update src/app/posts/PostCard/index.tsx

import Link from "next/link";

interface PostItemProps {
id: string;
title: string;
description: string;
createdAt?: Date;
}

const PostCard: React.FC<PostItemProps> = ({
id,
title,
description,
createdAt,
}) => {
return (
<div
key={id}
className="max-w-xl"
>
<div className="flex items-center gap-x-4 text-xs">
<time dateTime={createdAt?.toString()} className="text-gray-500">
{createdAt?.toLocaleDateString()}
</time>
</div>
<div className="group relative">
<h3 className="mt-3 text-lg font-semibold leading-6 text-gray-900 group-hover:text-gray-600">
<Link href={`/posts/${id}`}>{title}</Link>
</h3>
<p className="mt-5 line-clamp-3 text-sm leading-6 text-gray-600">
{description}
</p>
</div>
</div>
);
};

export default PostCard;

Please note that the line:

<Link href={`/posts/${id}`}>{title}</Link>

We add the link to post detail page. Now you can click to the post title in the list of posts to go to the post detail.

You have done an amazing job of completing the post list and post detail UI that fetch data from the server seamlessly with the useQuery hook from react-query. In the next part, we will explore how to build the post editor page that will allow you to work with the useMutation hook that will enable you to manipulate data with server api.

Index | < Pre | Next >

--

--