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.
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.
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)}
/>
);
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>
);
};
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}
/>
);
};
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 },
};
};
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;
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!]!
}
// ...
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
}
}
// ...
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:
// ...
4. Script in package.json
Add this script in the package.json and enter yarn generate
on your terminal.
"scripts": {
"generate": "graphql-codegen"
},
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;
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
}
}
}
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;
},
},
},
},
},
});
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>
// ...
);
};
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}</>;
};
This figure explains the routes logic.
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.
Top comments (0)