React: API Response Validation

Roman Andrieiev
5 min readSep 16, 2023

In this journey, we shed light to the practical elegance of React development. It’s not just about coding; it’s about crafting user-friendly interfaces and ensuring that API responses align with our expectations. Join us as we navigate this realm, unraveling the intricacies of React and the crucial importance of API response validation in our digital landscapes.

As a proud member of Anima AI, I can attest to the unwavering commitment to excellence that defines this remarkable team. With each project, Anima AI pushes the envelope of what’s achievable in the realm of solutions. Our collective dedication to innovation shines through in every line of code we write, every solution we develop, and every challenge we conquer.

If you like this article, join our community! There will be a lot of fun development enlightments in the future:
https://myanima.ai/
https://www.reddit.com/r/AnimaAI/

It’s crucial to have a powerful tool for validating and notifying inconsistencies in your frontend JavaScript application’s backend responses.

What if we expect one format for data, but backend throws us with empty response? How frontend will know about it? Let’s dive into it!

What We Will Use:

  • TypeScript
  • redux/js-toolkit
  • redux-saga + typed-redux-saga
  • apisauce
  • superstruct

All our efforts will revolve around superstruct as the core of response validation.

Project configuration

After app creation from the basic template, we will add some libraries (listed above), you can add it manually to your package.json file, or install with help of yarn .

Libraries’ versions I used in my project:

// package.json
...
"dependencies": {
"@reduxjs/toolkit": "1.9.1",
"apisauce": "1.1.2",
"react": "18.2.0",
"react-redux": "8.0.5",
"redux": "4.2.1",
"redux-saga": "1.2.2",
"superstruct": "1.0.3",
"typed-redux-saga": "1.5.0",
},
...

You can easily add these libraries to your project with yarn

Note: yarn will add the latest available versions from NPM

Now we can start with responses validation!

Implementation

First of all, we need to create our User schema and define all the types required for using it within Redux, Redux-Saga, and the API.

// exampleSchema.ts

// You can explore the documentation for superstruct to create more intricate schemas.
// For now, we'll start with a straightforward one.

import { Infer, number, string, type } from 'superstruct';

// Define a schema for the user to ensure validation of all expected fields.
export const UserSchema = type({
id: string(),
name: string(),
age: number(),
});

// Infer the type from the schema to create a standard TypeScript type.
export type User = Infer<typeof UserSchema>;

// Extract the ID type from User for added convenience.
export type UserID = User['id'];

Next, we can create a Redux slice and connect it with our rootReducer:

// exampleSlice.ts
// This file contains the Redux slice for the "example" feature

import { CaseReducer, createSlice, PayloadAction } from '@reduxjs/toolkit';

import { User } from './exampleSchema';

// Define the user and error types for this Redux slice
type ExampleState = {
user: User | null;
error: string | null;
};

// Initial values for the current slice
const INITIAL_STATE: ExampleState = {
user: null,
error: null,
};

// We will use this action to trigger our saga flow and initiate user fetching
const getUser: CaseReducer<ExampleState> = () => {};

// We will use this action to save the received user to the store
export type GetUserSucceededAction = PayloadAction<User>;
const getUserSucceeded: CaseReducer<ExampleState, GetUserSucceededAction> = (state, { payload: user }) => {
state.user = user;
};

// We will use this action to save the error to the store
const getUserFailed: CaseReducer<ExampleState, PayloadAction<string>> = (state, { payload: error }) => {
state.error = error;
};

// Create a slice for the "example" feature with all the CaseReducers defined above
const exampleSlice = createSlice({
name: 'example',
initialState: INITIAL_STATE,
reducers: {
getUser,
getUserSucceeded,
getUserFailed,
},
});

// Export all actions with an `Action` suffix to avoid naming conflicts
export const {
getUser: getUserAction,
getUserSucceeded: getUserSucceededAction,
getUserFailed: getUserFailedAction,
} = exampleSlice.actions;

export const exampleReducer = exampleSlice.reducer;

Let’s define the core API and enhance it with schema validation for all types of requests (POST, GET, PUT, PATCH, DELETE):

// apiClient.ts
// Basic template from "apisauce" library

import apisauce, { ApisauceInstance } from 'apisauce';

import { API_BASE_URL, API_DEFAULT_HEADERS, API_TIMEOUT } from '#constants';

export const apiClient: ApisauceInstance = apisauce.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: API_DEFAULT_HEADERS,
});

export const setAuthHeader = (token: string) => {
apiClient.setHeader('Authorization', `Bearer ${token}`);
};

export const clearAuthHeader = () => {
apiClient.deleteHeader('Authorization');
};
// apiClientWithSchemaValidation.ts
// Our custom modification for apiClient to check all responses from the backend!

import { ApiResponse } from 'apisauce';
// apisauce also includes "axios" library
import { AxiosRequestConfig, Method } from 'axios';
import { create, Struct } from 'superstruct';

import { apiClient } from './apiClient';

const apiRequestWithSchemaValidation = async <TExpectedData>(
params: Parameters<typeof apiClient.any<TExpectedData>>,
schema?: Struct<Any>,
): Promise<ApiResponse<TExpectedData>> => {
const [requestConfig] = params;

const response = await apiClient.any<TExpectedData>(requestConfig);
const actualResponseData = response.data as TExpectedData;

// Only if schema is passed here
if (schema) {
try {
// Validation
create(actualResponseData, schema);
} catch (error) {
// If there any mistakes in response - this statement will catch all for us

// You can add any analytic handlers here
console.log(`Superstruct validation error, URL: ${requestConfig.baseURL}${requestConfig.url}`);
}
}

return response;
};

const createRequestWithDataHandler =
(method: Method) =>
<TSuccessResponse>(url: string, data?: Any, schema?: Struct<Any>, additionalConfig: AxiosRequestConfig = {}) =>
apiRequestWithSchemaValidation<TSuccessResponse>(
[
{
url,
method,
data,
...additionalConfig,
},
],
schema,
);

export const apiClientWithSchemaValidation = {
// Validated GET
get: <TSuccessResponse>(url: string, schema?: Struct<Any>, additionalConfig: AxiosRequestConfig = {}) =>
apiRequestWithSchemaValidation<TSuccessResponse>(
[
{
url,
method: 'GET',
...additionalConfig,
},
],
schema,
),

// Validated POST
post: createRequestWithDataHandler('POST'),

// Validated PUT
put: createRequestWithDataHandler('PUT'),

// Validated DELETE
delete: createRequestWithDataHandler('DELETE'),
};

Let’s define an apiInstance for our 'example' segment of APIs:


// exampleApi.ts

import { User, UserSchema } from './exampleSchema';

import { apiClientWithSchemaValidation } from './apiClientWithSchemaValidation';

// Call our "apiClientWithSchemaValidation.get, define type param as User and schema
const getUser = () => apiClientWithSchemaValidation.get<User>('api/auth/user', UserSchema);

export const exampleApi = {
getUser,
};

And the final exampleSaga.ts :

import { call, put, takeLatest } from 'typed-redux-saga';

// apiInstance from the code snippets above
import { apiInstance } from '#services/api';

// RootState where we connect our exampleSlice.reducer
import { getUserAction, getUserFailedAction, getUserSucceededAction } from './exampleSlice';

function* getUserWorker() {
// After this call we'll get the console.log error if there any mistakes in response!
const response = yield* call(apiInstance.example.getUser);

if (response.ok && response.data) {
yield* put(getUserSucceededAction(response.data));
} else {
yield* put(getUserFailedAction('Something went wrong'));
}
}

// Connect this to your root saga
export function* exampleSaga() {
yield* takeLatest(getUserAction, getUserWorker);
}

If you need to check the result, you can easily dispatch an action from any component:

// MyComponents.tsx

import { useEffect } from 'react';

import { getUserAction } from './exampleSlice';

export const MyComponent = () => {
useEffect(() => {
// Make this call only on component mount once
dispatch(getUserAction());
}, []);

return <div></div>;
}

Conclusion

In this journey of building a robust frontend application, we’ve explored the importance of API response validation and how it can enhance the reliability of our application. By utilizing tools like TypeScript, Redux, Redux-Saga, and Superstruct, we’ve laid the foundation for creating structured and validated responses from our backend. We’ve also seen how to seamlessly integrate these components into our Redux slices and API calls.

With these practices in place, you’re well-equipped to ensure that your frontend remains resilient in the face of ever-changing backend responses. Whether you’re fetching data or sending updates, the confidence that comes from validated API responses is a valuable asset in delivering a seamless user experience.

As you continue your development journey, remember that the tools and techniques we’ve discussed here are just the beginning. The world of frontend development is rich and ever-evolving, so keep exploring and building upon these foundations to create powerful and reliable applications. Happy coding!

--

--