Reactを使ったプロダクトリニューアル:「AUBA for Admin」ディレクトリ構成 - eiicon Tech ブログ #1
eiicon noteをご覧のみなさま、こんにちは!開発Gフロントエンドエンジニアの上野です。今回、2023年4月にリニューアルした「AUBA for Admin」について数回に分けて技術的な部分をご紹介するため、本記事を執筆しました。この記事では、フロント側のディレクトリ構成についてご紹介させていただきたいと思います。
「AUBA for Admin」とは
弊社では自社プロダクトとしてオープンイノベーションプラットフォーム「AUBA」と「TOMORUBA」というオウンドメディアを運営しています。「AUBA for Admin」は「AUBA」にご登録いただいているユーザ様の管理や「TOMORUBA」への記事投稿などを行うことができる、弊社メンバーが使用する管理画面となります。
今回、この「AUBA for Admin」での業務効率改善を目的として、画面デザインも含めた全面リニューアルを実施しました。フロント側はReactで構成し、バックエンドとのやり取りにはGraphQLを使用した構成に再構築しました。
ディレクトリ構成
私はeiiconに入社するまでReactを業務で使用した経験がありませんでした。そのため、まずReactではどのような構成にするのがベストかといったことを調査するところから始めました。調査をしていくと「bulletproof-react」というGitHubリポジトリで公開されているディレクトリ構成が良いという記事を見つけました。
また、弊社ではすでにNuxt.jsで構築されたプロダクトがあったため、Nuxt.jsのディレクトリ構成に寄せるのも良いのではないかと考えました。
以上から、Nuxt.jsのディレクトリ構成をベースに「bulletproof-react」も一部取り入れた構成にしていこうという意思決定を行いました。下記が実際に使用しているディレクトリ構成になります。
※同じディレクトリ内にバックエンドで使用しているRubyなどのファイル群も存在しますが今回は割愛させていただきます
auba-admin
├ app
│ └ javascript
│ └ src
│ ├ .storybook
│ ├ components
│ ├ config
│ ├ graphql
│ ├ layouts
│ ├ libs
│ ├ pages
│ ├ providers
│ ├ store
│ ├ styles
│ ├ types
│ └ utils
├ public
├ esbuild.js
├ package.json
├ yarn.lock
└ tsconfig.json
「components」、「layouts」、「pages」といったNuxt.jsでお馴染みの構成をベースに、「providers」ディレクトリなどは「bulletproof-react」を参考に取り入れました。
今回はこの中からいくつかのディレクトリの役割について、ご紹介させていただきます。
providers
サイト全体の設定などを記述した共通コンポーネントを格納するためのディレクトリです。「AUBA for Admin」では、GraphQLでのやり取りに「Apollo」、バリデーションに「Zod」、コンポーネントライブラリおよびスタイリングに「MUI」と「Emotion」を使用しています。主にこれらのライブラリで使用する共通設定を「providers」ディレクトリ内のコンポーネントに記述し、後述する各「pages」ディレクトリのコンポーネントで使用しています。
providers/app.tsx
import React, { ReactElement } from 'react'
import { ApolloClient, InMemoryCache, HttpLink, ApolloProvider } from '@apollo/client'
import { ZodProvider } from 'libs/validation/ZodProvider'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { defaultTheme } from 'styles/theme'
import { BrowserRouter } from 'react-router-dom'
import 'styles/App.css'
export const AppProvider = (props: { children: ReactElement }) => {
const client = new ApolloClient({
〜 中略 〜
})
const { children } = props
return (
<React.StrictMode>
<ZodProvider>
<ApolloProvider client={client}>
<ThemeProvider theme={defaultTheme}>
<CssBaseline />
<BrowserRouter>{children}</BrowserRouter>
</ThemeProvider>
</ApolloProvider>
</ZodProvider>
</React.StrictMode>
)
}
layouts
ページ全体の構成を定義したコンポーネントを格納するためのディレクトリです。「AUBA for Admin」ではコンテンツのみを表示させるシンプルな画面とサイドメニューなども表示させる2パターンのレイアウトが存在するため、それぞれのコンポーネントを用意しています。
layouts/SideMenu/index.tsx
import { ReactElement } from 'react'
import { useAtom } from 'jotai'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
import { getIsShowWrapSpinnerAtom } from 'store/wrapSpinner'
import { Header } from 'components/common/Header'
import { SideMenu } from 'components/common/SideMenu'
import { Footer } from 'components/common/Footer'
import { WrapSpinner } from 'components/ui/spinners/WrapSpinner'
import { FadeTransition } from 'components/transition/FadeTransition'
import { PopupAlert } from 'components/ui/alerts/PopupAlert'
import { layoutSideMenuStyle } from './style'
export const LayoutSideMenu = (props: { children: ReactElement }) => {
const { children } = props
const [isShowWrapSpinner] = useAtom(getIsShowWrapSpinnerAtom)
return (
<Box css={layoutSideMenuStyle} className="LayoutWrapper">
<Grid container className="LayoutParallel">
<Grid item component="aside" className="LayoutParallel__side">
<SideMenu />
</Grid>
<Grid item className={`LayoutParallel__main`}>
<Header />
<Box component="main" className="LayoutMain">
{children}
</Box>
<Footer />
</Grid>
</Grid>
<FadeTransition isShow={isShowWrapSpinner} transitionSpeed={0}>
<WrapSpinner isBackgroundTransmission />
</FadeTransition>
<PopupAlert />
</Box>
)
}
pages
各画面で使用するコンポーネントを格納するためのディレクトリです。Nuxt.jsやNext.jsにおける「pages」ディレクトリと近い役割を担っています。実際のURLベースでディレクトリが作られていて、各ディレクトリにはそれぞれ「index.tsx」と「App.tsx」が配置されています。
index.tsx:providersやlayoutsなどの基本的な設定を読み込みます
App.tsx:データの取得など各画面で必要な処理を記述しています
pages/login/index.tsx
import { createRoot } from 'react-dom/client'
import { AppProvider } from 'providers/app'
import { LayoutDefault } from 'layouts/Base'
import { App } from './App'
const container = document.getElementById('root')
if (document.getElementById('eiicon-app') && container) {
const root = createRoot(container)
root.render(
<AppProvider>
<LayoutDefault>
<App />
</LayoutDefault>
</AppProvider>
)
}
pages/login/App.tsx
import React, { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as zod from 'zod'
import { rhfUseFormCustomOptions } from 'libs/validation/rhfCustomOptions'
import { customErrorMessage } from 'libs/validation/zodCustomErrorMessage'
import { appStyle } from './style'
import { Inner } from 'components/layout/Inner'
〜 中略 〜
type FormInputs = {
email: string
password: string
}
const formSchema = zod.object({
email: zod
.string()
.nonempty({
message: customErrorMessage.required('Eメール'),
})
.email({
message: customErrorMessage.email('Eメール'),
}),
password: zod.string().nonempty({
message: customErrorMessage.required('パスワード'),
}),
})
export const App = () => {
const { control, handleSubmit } = useForm<FormInputs>({
...rhfUseFormCustomOptions,
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
})
〜 中略 〜
return (
<div className="Login" css={appStyle}>
<div className="Login__container">
<Inner>
<div className="Login__contents">
〜 中略 〜
</div>
</Inner>
</div>
</div>
)
}
さいごに
今回は弊社プロダクトのディレクトリ構成について紹介させていただきました。初めて本格的に触ったReactでしたが、今後の開発なども考慮したディレクトリ構成にできたのではと感じています。今回ご紹介できなかった部分についても今後の記事でお伝えできればと思います。
私は2022年2月にeiiconへ入社しましたが、エンジニアの裁量が大きく、自由に開発が行える環境があると感じています。この記事などを通して少しでもeiiconに興味を持った方は、是非一度カジュアル面談でeiicon社員とお話してみてください。
カジュアル面談積極実施中💡