DEV Community

Cover image for React & GraphQL with MVP
Ryutaro Umezuki
Ryutaro Umezuki

Posted on

React & GraphQL with MVP

I have created a simple thread app using an MVP pattern. The features are:

  • Authorization
  • Posting information on threads.
  • Searching users by email

I will share the architectural knowledge I have acquired in the process.
Feel free to download it from its Github repository.

MVP

Before starting, I have to say that I adopted the Passive View, not the Supervising Controller. Anyway, let’s grasp the points of the MVP. MVP stands for Model-View-Presenter and is used mainly for building user interfaces. Each layer role:

  • The Model is responsible for application logic and data management.
  • The Presenter acts as a bridge between the View and the Model.
  • The View contains only UI presentation and the logic - components, local state, button clicks, etc. In other words, you should not include any application logic here.

Model-View-Presenter

They enable the separation of concerns between the application and the UI. It means complicated tasks are broken down into simple ones. Besides, you can do unit tests. However, keep in mind that the MVP is not a magic bullet. It has disadvantages such as the increase in the interfaces.

I felt it was a great concept and decided to adopt the pattern, but it was little abstract. On that occasion, I found the below practical image in Khalil Stemmler’s article. I referred to his ideas a lot.

Client-Side Architecture Basics
Image credits: khalilstemmler.com

Folder structure

You consider each directory in the root of the src as follows.

  • components/ - react components
  • pages/ - most of them are also containers
  • interactions/ - interaction(application) logics
  • infra/ - state management and communicating with server-side
  • hooks/ - shared custom hooks
  • providers/ - application providers
  • routes/ - routes configuration
  • types/ - type aliases
  • utils/ - shared utility functions

View

Components/

Components are categorized into Elements and Others.

Components/Elements/

Minimum components are defined such as Input and Button. I used Chakra UI and React Icons for the UI libraries, and only this folder depends on them. (Of course, it is necessary to import the hooks and providers from other places). As a result, if you need to replace them with other libraries, you can do it smoothly because the dependencies concentrate on the folder.

I just wrapped the libraries' components basically but customized its props as needed.

// components/Elements/Button/Button.tsx

import { Input as ChakraInput, InputProps } from "@chakra-ui/react";

type Props = Omit<InputProps, "onChange"> & {
    id: string;
    onChange: (value: string, id: string) => void;
};

export const FormInput = ({ id, onChange, ...props }: Props) => (
    <ChakraInput
        {...props}
        size="lg"
        onChange={(e) => onChange(e.target.value, id)}
    />
);

Enter fullscreen mode Exit fullscreen mode

Components/Others/

These presentational(UI) components consist of Elements and the other components. I have separated the stateful logic from the presentation by React Hooks to distinguish the roles visually. In the hooks, you deal with event handlers, page navigation, local(component) state management, etc. If you develop a medium or larger project, I suggest you create a new folder for the logic (the hooks) in order to keep the readability.

Regarding style strongly dependent on the application design, such as the application brand colors, it is better to have a global style state considering modifiability.

// components/Sections/AccountSection.tsx

import { useCallback, useState } from "react";
import { Box, Button, Center, Text } from "components/Elements";
import { theme } from "utils/theme";
import { ThreadLayout } from "components/Layout";
import { Form } from "components/Form";
import { ChangePassword, SignOut } from "types";
import useCustomToast from "hooks/useCustomToast";
// ...
const useAccount = ({ id, actions }: Input) => {
    const list = [
        // ...
    ];

    const initValue = {
        oldPassword: "",
        newPassword: "",
    };
    const [state, setState] = useState(initValue);

    const { setSuccess } = useCustomToast();

    const handleUpdatePassword = async () => {
        await actions.changePassword({
            id: id,
            ...state,
        });
        // if handleUpdatePassword throws error,
        // below setSuccess and setState won't run.
        setSuccess({ title: "Password changed ", description: "" });
        setState(initValue);
    };

    return {
        models: { list, state },
        operations: { handleFormInput, handleUpdatePassword, handleSignOut },
    };
};

// ...

export const AccountSection: React.FC<Props> = ({ id, actions, error }) => {
    const { models, operations } = useAccount({ id, actions });
    return (
        <ThreadLayout page="Account">
            // ...
            <Button
                onClick={operations.handleUpdatePassword}
                w={"100%"}
                mb={theme.m.md}
            >
                Update Password
            </Button>
            // ...
        </ThreadLayout>
    );
};
Enter fullscreen mode Exit fullscreen mode

Presenter

pages/

This directory plays a role in containers/presenters as well as pages. However, note that it is different from the presentational/container pattern. In the design, the container handles the entire component logic. Unlike the conventional definition, this container is a bridge between a View and a Model. It is responsible for delegating user actions from the View to Models, to be specific, to interactions, and passing data to View.

// pages/Account.container.tsx

import { useAuth, useUser } from "interactions";
import { AccountSection } from "components/Sections";
// ...
export const Account = ({ id }: Props) => {
    const { operations: authOperations } = useAuth();
    const { error, operations: userOperations } = useUser();
    const { signOut } = authOperations;
    const { changePassword } = userOperations;
    return (
        <AccountSection
            actions={{ signOut, changePassword }}
            id={id}
            error={error}
        />
    );
};


Enter fullscreen mode Exit fullscreen mode

It is few, but some pages may not need the presenters because the view and the model do not communicate. For example, a 404 page is not a container in this project.

Model

interactions/

Interaction(application) logic is described. It includes:

  • logical decision
  • validation
  • app calculation
  • format conversion
// interactions/useUser.ts

import {
    MutationUpdatePasswordArgs,
    QueryFetchUserByEmailArgs,
} from "infra/codegen";
import { useUserOperations } from "infra/operations";
import { useState } from "react";
import { passwordValidation } from "utils/passwordValidation";

export const useUser = () => {
    const [error, setError] = useState("");
    const { models, queries, mutations } = useUserOperations();
    const { user } = models;

    const changePassword = async (args: MutationUpdatePasswordArgs) => {
        const oldPasswordError = passwordValidation(args.oldPassword);
        const newPasswordError = passwordValidation(args.newPassword);
        const errorMessage = oldPasswordError || newPasswordError;
        if (errorMessage) {
            setError(errorMessage);
            return;
        }
        await mutations.updatePassword(args);
    };
    // ...
    return {
        models: { user },
        error,
        operations: { changePassword, searchUser },
    };
};
Enter fullscreen mode Exit fullscreen mode

Error handling

Maybe you put API error logic in the interaction layer or similar place, but I used a global state for them inside of useClient. They are detected automatically, so you do not need to write them. If errors themselves or the UI logic are complex, you had better create the respective error handlings in this layer.

// hooks/useClient.ts

import { useMemo } from "react";
import { ApolloClient, ApolloLink, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import storage from "utils/storage";
import { onError } from "@apollo/client/link/error";
import { cache } from "infra/stores/cache";
import useCustomToast from "hooks/useCustomToast";

const useClient = () => {
    // ...
    const errorLink = useMemo(() => {
        return onError(({ graphQLErrors, networkError }) => {
            if (graphQLErrors) {
                graphQLErrors.map(({ message, locations, path }) => {
                    if (path && path[0] !== "fetchUserByToken") {
                        setError({
                            title: `${message}`,
                            description: "Will you please try one more time?",
                        });
                    }
                    return console.log(
                        `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
                    );
                });
            }
            if (networkError) {
                setError({
                    title: `${networkError.message}`,
                    description: "Will you please try one more time?",
                });
                console.log(`[Network error]: ${networkError}`);
            }
        });
    }, [setError]);

    const client = useMemo(() => {
        return new ApolloClient({
            // You should care the order of below links!
            link: ApolloLink.from([errorLink, authLink, httpLink]),
            cache: cache,
            connectToDevTools: true,
        });
    }, [httpLink, authLink, errorLink]);
    return { client };
};

export default useClient;

Enter fullscreen mode Exit fullscreen mode

infra/

Infrastructure is a layer that accesses the server-side and manages data. I chose Apollo Client for that. According to the official,

Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI.

4 steps for Apollo code generator.

GraphQL Code Generator is handy. It generates the typed Queries, Mutations, and Subscriptions from the GraphQL schema.

1. Schema

Design type schema - User, Post, Query, and Mutation. The detail

// infra/schema.gql

type User {
    id: ID!
    token: String
    email: String!
    password: String!
    country: String!
    city: String!
    nickName: String!
    posts: [Post!]!
}

// ...
Enter fullscreen mode Exit fullscreen mode

2. Query and Mutation

One of the differences between Rest API and GraphQL is the number of endpoints. The former has multiple endpoints containing its own URI(Uniform Resource Identifier). In contrast, GraphQL needs only one. How does the system identify each request content? The answer is to describe the exact data structure expected as a response in the query or mutation. The architecture concept prevents over-fetching and under-fetching data.

Apollo Studio is a good choice for testing queries and mutations.

Apollo Studio is a cloud platform that helps you build, validate, and secure your organization's graph.

// infra/mutations/user.gql

// ...
mutation CreateUser(
    $email: String!
    $password: String!
    $country: String!
    $city: String!
    $nickName: String!
) {
    createUser(
        email: $email
        password: $password
        country: $country
        city: $city
        nickName: $nickName
    ) {
        token
    }
}
// ...
Enter fullscreen mode Exit fullscreen mode

3. codegen.yml

Inform the code generator of the path of the schema, queries, and mutations

schema: src/infra/schema.gql
documents:
  - src/infra/queries/*.gql
  - src/infra/mutations/*.gql
generates:
  src/infra/codegen.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
  server/codegen.ts:
    // ...

Enter fullscreen mode Exit fullscreen mode

4. Script in package.json

Add this script in the package.json and enter yarn generate on your terminal.

   "scripts": {
        "generate": "graphql-codegen"
    },
Enter fullscreen mode Exit fullscreen mode

infra/operations

This is in charge of data access by using useMutation and useQuery generated through the code generator described just before. In addition, you modify cache and reactive variables in this layer.

Loading and Error

Apollo Client has loading and error about API communication, and you can use them without creating by yourselves.

// infra/operations/usePostOperations.ts

import {
    FetchUserByEmailDocument,
    FetchUserByTokenDocument,
    MutationCreatePostArgs,
    useCreatePostMutation,
} from "infra/codegen";
import { cache } from "infra/stores/cache";
import { User } from "types";
// ...
export const usePostOperations: () => { mutations: Mutations } = () => {
    const [CREATE_POST_MUTATION] = useCreatePostMutation();
    const createPost: (
        args: MutationCreatePostArgs,
        user: User,
        queryName: "fetchUserByToken" | "fetchUserByEmail"
    ) => Promise<void> = async (args, user, queryName) => {
        await CREATE_POST_MUTATION({
            variables: args,
        }).then((res) => {
            if (!res.data) throw new Error("Response data is undefined");
            const posts = user.posts;
            const newPost = res.data.createPost;
            const query =
                queryName === "fetchUserByToken"
                    ? FetchUserByTokenDocument
                    : FetchUserByEmailDocument;
            cache.updateQuery({ query }, () => ({
                [queryName]: {
                    ...user,
                    posts: [newPost, ...posts],
                },
            }));
        });
    };
    return { mutations: { createPost } };
};

export default usePostOperations;

Enter fullscreen mode Exit fullscreen mode

infra/stores/

You can use Apollo Client cache and reactive variables instead of Redux and React Hooks for store and state management.

How to handle client-side state

You sometimes have to manage the state used only client-side such as a post date converted from a timestamp. By adding @client to the date, you can deal with the client-side state and the query response together. It removes redundant code and clears up the data handling.

// infra/queries/user.gql

query FetchUserByEmail($email: String!) {
    fetchUserByEmail(email: $email) {
        id
        email
        country
        city
        nickName
        posts {
            id
            body
            createdAt
            senderEmail
            date @client
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It must also be written which field of the cache will be modified when the queries or mutations run.

// infra/stores/cache.ts

import { InMemoryCache } from "@apollo/client";
import { timestampToDate } from "utils/timestampToDate";

export const cache = new InMemoryCache({
    typePolicies: {
        Post: {
            fields: {
                date: {
                    read(_, opts) {
                        const timestamp = (opts.readField("createdAt") as number) * 1000;
                        const date = timestampToDate(timestamp);
                        return date;
                    },
                },
            },
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

Others

providers/

All providers consolidate into this folder.

// providers/AppProvider.tsx

import { ChakraProvider } from "@chakra-ui/react";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { BrowserRouter as Router } from "react-router-dom";
import { IconContext } from "react-icons";
import { theme } from "utils/theme";
import { ApolloProvider } from "@apollo/client";
import useClient from "hooks/useClient";
// ...
export const AppProvider = ({ children }: Props) => {
    const { client } = useClient();
    return (
        // ...
        <ChakraProvider>
            <ApolloProvider client={client}>
                <IconContext.Provider value={{ color: theme.color.blue, size: "32px" }}>
                    <Router>{children}</Router>
                </IconContext.Provider>
            </ApolloProvider>
        </ChakraProvider>
        // ...
    );
};

Enter fullscreen mode Exit fullscreen mode

routes/

I split the routes into protected, public, and redirect.

// routes/index.tsx

import { useRoutes } from "react-router-dom";
import { publicRoutes } from "routes/public";
import { protectedRoutes } from "routes/protected";
import { useAuth } from "interactions";
import { Error404 } from "pages";
import { authStore } from "infra/stores/authStore";

export const AppRoutes = () => {
    const { loading } = useAuth();
    const id = authStore();
    const routes = id ? protectedRoutes(id) : publicRoutes;
    const redirectRoutes = [
        { path: "*", element: <Error404 loading={loading} id={id} /> },
    ];

    const element = useRoutes([...routes, ...redirectRoutes]);
    return <>{element}</>;
};

Enter fullscreen mode Exit fullscreen mode

This figure explains the routes logic.

Routes navigate figure

Conclusion

The MVP pattern makes the individual layer roles distinct and reduces the scope of library dependencies. Consequently, the app becomes loose coupling and improves its maintainability.

References

Sources

Articles

Top comments (0)