Decoupling Data Fetching of This Blog
2024-08-20
2024-08-20
Currently, this blog fetches data from an external REST API. You can find more details here.
In my recent work , I focused on decoupling my components from the data source. My goal was to transition from code like this:
export default async function Home({
searchParams,
}: {
searchParams?: HomeSearchParams;
}) {
const posts = await fetch("https://rest-api-url.com/");
Here, we're making a fetch
call to an external REST API to retrieve post objects.
To something like this:
export default async function Home({
searchParams,
}: {
searchParams?: HomeSearchParams;
}) {
const posts = await activeDataProvider.getAll();
With these changes, we introduced a new layer between data-fetching operations and the component itself. I refer to this layer as the "data provider." I defined an interface specifying the required and optional methods for a data provider:
export interface IDataProvider {
getAll(options: PostSearchOptions): Promise<PaginatedPosts>;
getBySlug(slug: string): Promise<Post | null>;
create?(data: Partial<Post>): Promise<Post>;
update?(slug: string, data: Partial<Post>): Promise<Post | null>;
delete?(slug: string): Promise<boolean>;
}
This approach allows us to easily switch data sources in the future. For example, if we decide to fetch data directly from a database, we would simply create a new DbDataProvider
that implements IDataProvider
.
We would then only need to update the data-providers/active.ts
file to use the new DbDataProvider
:
import { DbAPIDataProvider } from './db';
const activeDataProvider = new DbAPIDataProvider();
export default activeDataProvider;
By modifying just one file (after creating the new data provider), you can change the app's persistence layer.
Another significant benefit of this approach is improved testability. Initially, I aimed to replace the active data provider with a TestDataProvider
that returns hard-coded data for unit tests. I planned to inject the active data provider as a dependency into Next.js page components like this:
export default async function Home({
dataProvider = activeDataProvider,
searchParams,
}: HomeProps) {
...
This setup allowed me to pass the test data provider as a parameter to the component:
<Suspense>
<Home searchParams={searchParams} dataProvider={testDataProvider} />
</Suspense>
While this worked well in development, I encountered errors when running next build
, such as:
Type error: Page "app/page.tsx" has an invalid "default" export:
Type "HomeProps" is not valid.
ELIFECYCLE Command failed with exit code 1.
Error: Command "pnpm run build" exited with 1
The issue was that Next.js components cannot accept parameters other than params
or searchParams
(source).
Since dependency injection was not possible, I ended up using spyOn
calls in my unit tests. Although I aimed to avoid mocks and spies, I couldn't find an alternative when dependency injection wasn't feasible.
Despite this, the testability of the code improved. For example, the test case initially looked like this:
import { getPostsAndTotalPages } from "../../app/lib/fetchPosts";
test("Home page component should match the snapshot", async () => {
const searchParams = {
query: "",
page: "1",
};
const getPostsAndTotalPagesMock = getPostsAndTotalPages as Mock;
getPostsAndTotalPagesMock.mockResolvedValue({
posts: generateMockPostAPIResponse().results,
totalPages: 2,
});
const { container } = render(
<Suspense>
<Home searchParams={searchParams} />
</Suspense>
);
// Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
await screen.findByText("Post 1");
expect(container).toMatchSnapshot();
});
After the changes, it now looks like this:
const jsonData = JSON.parse(readFileSync('tests/test-data.json', 'utf-8'));
const memoryDataProvider = new MemoryDataProvider(jsonData);
test('Component should match the snapshot', async () => {
const postSlug = 'post-1';
const params = {
slug: postSlug,
};
vi.spyOn(
activeDataProvider,
'getSinglePostFromStorage',
).mockImplementation(() => memoryDataProvider.getSinglePostFromStorage(postSlug));
const { container } = render(
<Suspense>
<SinglePostPage params={params} />
</Suspense>,
);
// Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
await screen.findByText('Post 1');
expect(container).toMatchSnapshot();
});
The revised test case is now less coupled to the implementation details of fetching post data. This makes the tests more robust and simplifies future code changes.
I hope some of this can also be helpful for you. Happy decoupling! 🚀