🙏

僕が4ヶ月で学んだReactでの分割

2024/12/24に公開

はじめに

本記事は日本CTO協会24卒 Advent Calendar 2024の10日目の記事です遅れてごめんなさい!

自分は今年の4月に入社したどすこいというエンジニアです。4月から研修をしており、8月ごろからWebエンジニアとして事業部に所属して開発しています。Reactを触ったのはほとんど初めてでした。

今回は、それで学んだことが見えるのはリファクタリングかなぁとおもったので、やってみます!

注:今回はファイルの分割の部分をメインで紹介します。コードが動くところや細かい部分はちょっと後回しに描いちゃってますのでご容赦ください。

対象のサンプルコード

import { useEffect, useState } from 'react'
import { fetchUserData, saveUserData } from './api'
import { Footer } from './components/Footer'
import { Header } from './components/Header'
import { Settings } from './components/Settings'
import { UserProfile } from './components/UserProfile'
import './styles.css'

export default function App() {
  const [userData, setUserData] = useState(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  const [tempSettings, setTempSettings] = useState(null)
  const [showSettings, setShowSettings] = useState(false)

  // Load user data
  useEffect(() => {
    const loadData = async () => {
      setIsLoading(true)
      try {
        const data = await fetchUserData()
        setUserData(data)
        setTempSettings(data.settings)
      } catch (err) {
        setError(err)
      } finally {
        setIsLoading(false)
      }
    }
    loadData()
  }, [])

  // Save settings when they are changed
  const handleSaveSettings = async () => {
    if (tempSettings) {
      try {
        setIsLoading(true)
        await saveUserData({ ...userData, settings: tempSettings })
        setUserData({ ...userData, settings: tempSettings })
        setShowSettings(false)
      } catch (err) {
        alert('Failed to save settings')
      } finally {
        setIsLoading(false)
      }
    }
  }

  const handleSettingsToggle = () => {
    setShowSettings(!showSettings)
  }

  return (
    <div className='app-container'>
      <Header title='User Management App' />
      {isLoading ? (
        <p>Loading...</p>
      ) : error ? (
        <p style={{ color: 'red' }}>Error: {error.message}</p>
      ) : (
        <div>
          {userData ? (
            <>
              <UserProfile user={userData} />
              <button onClick={handleSettingsToggle}>
                {showSettings ? 'Hide' : 'Show'} Settings
              </button>
              {showSettings && (
                <div>
                  <Settings
                    settings={tempSettings}
                    onChange={(newSettings) => setTempSettings(newSettings)}
                  />
                  <button
                    onClick={handleSaveSettings}
                    style={{
                      backgroundColor: 'blue',
                      color: 'white',
                      marginTop: '10px',
                      padding: '5px',
                    }}
                  >
                    Save Settings
                  </button>
                </div>
              )}
            </>
          ) : (
            <p>No user data available</p>
          )}
        </div>
      )}
      <Footer />
    </div>
  )
}

やっていくぞ

方針

チームでは、index.tsx, view.tsx, hooks.ts, model.ts,index.test.tsxというようにファイルを分けてコンポーネント内を構成しています。**今回はこの分けるところをメインに紹介します。**例えば、index.tsxはComponentのエントリーポイントとして機能し、hooks.tsから状態をimportし、view.tsxに渡すという役割があります。これをする目的は、責務の分離をすることです。このルールはContainer/Presentationalパターンをベースに決めました。

index.tsx

import { useMainApp } from './hooks'
import { MainAppView } from './view'

export function MainApp(): JSX.Element {
  const hooks = useMainApp()
  return (
    <MainAppView
      user={hooks.user}
      isLoading={hooks.isLoading}
      error={hooks.error}
      showSettings={hooks.showSettings}
      onToggleSettings={hooks.toggleSettings}
      onSaveSettings={hooks.handleSaveSettings}
    />
  )
}

ここの責務は。アプリをブラウザに描画するのに必要な最小限のコードのみです。ロジックや大規模な UI は含まれない必要があるので、こんな感じになります。
hooksの戻り値であることをわかるように分割代入を行わずにhooks.userのように呼び出しています。

hooks.ts

import { useEffect, useState } from 'react'
import { fetchUserData, saveUserData } from './utils'
import type { User } from './utils'

export function useMainApp() {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  const [showSettings, setShowSettings] = useState(false)

  useEffect(() => {
    const loadUser = async () => {
      try {
        setIsLoading(true)
        const data = await fetchUserData()
        setUser(data)
      } catch (err) {
        setError(err as Error)
      } finally {
        setIsLoading(false)
      }
    }
    loadUser()
  }, [])

  const toggleSettings = () => {
    setShowSettings((prev) => !prev)
  }

  const handleSaveSettings = async (newSettings: any) => {
    if (!user) return
    const updatedUser = { ...user, settings: newSettings }
    try {
      setIsLoading(true)
      await saveUserData(updatedUser)
      setUser(updatedUser)
      setShowSettings(false)
    } catch (err) {
      setError(err as Error)
    } finally {
      setIsLoading(false)
    }
  }

  return {
    user,
    isLoading,
    error,
    showSettings,
    toggleSettings,
    handleSaveSettings
  }
}

ここでの責務は状態管理です。APIだったりuseEffectを使ったりするところはここになります。
また、今回は引数がなかったですが、引数がある場合は分割代入を行わずにparams.hogeというように呼び出しています。

view.tsx

import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import { SettingsPanel } from '@/components/SettingsPanel'
import { UserProfile } from '@/components/UserProfile'
import type { User } from './utils'

interface Params {
  user: User | null
  isLoading: boolean
  error: Error | null
  showSettings: boolean
  onToggleSettings: () => void
  onSaveSettings: (newSettings: any) => void
}

export function MainAppView(params: Params): JSX.Element {
  return (
    <div style={{ padding: '20px' }}>
      <Header />

      {params.isLoading && <p>Loading...</p>}
      {params.error && <p style={{ color: 'red' }}>Error: {params.error.message}</p>}

      {!params.isLoading && !params.error && params.user ? (
        <>
          <UserProfile user={params.user} />
          <button onClick={params.onToggleSettings}>
            {params.showSettings ? 'Hide' : 'Show'} Settings
          </button>
          {params.showSettings && (
            <SettingsPanel settings={params.user.settings} onSave={params.onSaveSettings} />
          )}
        </>
      ) : (
        !params.isLoading && <p>No user data available</p>
      )}

      <Footer />
    </div>
  )
}

ここでの責務は描画です。ダミーデータを入れてデザイナーの方が整えてくださることもあるところです。また、個人的に触ってて楽しいこともあればイライラすることもある、感情豊かな領域です。フロントのイメージを議論するときにはここをよく弄ります。

あと、今回は一つのコンポーネントにしましたが、実際にはここからさらに子コンポーネントに分けていくことがよくあります。そのときには、子コンポーネントどうしや、親コンポーネントの結合が疎結合になるように気をつけます。 具体的には、子コンポーネントが追加されたり、消されたりした時の変更差分が少ないように実装します。引数を不要に渡さないようにしたり、受け渡しする引数が各コンポーネントで必要な情報だけにするよう気をつけたりします。ここに関してもっと深められそうなので、今度機会があったら記事にしたいです。

その他

utils.tsは、ダミーのAPIなどを書いたり、便利関数を書いたりします。マジで名前の通りのファイルで、書かないことが多いです。
また、型の情報はmodel.tsに書きます。useContextを使う時などに活躍するので、結構頻出に書きます。

utils.ts

// ダミーAPI(ユーザーデータ取得)
export async function fetchUserData(): Promise<User> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id: 1,
        name: 'John Doe',
        email: 'john.doe@example.com',
        settings: {
          theme: 'dark',
          notifications: true,
        },
      })
    }, 500)
  })
}

// ダミーAPI(ユーザーデータ保存)
export async function saveUserData(user: User): Promise<void> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (user.id) {
        resolve()
      } else {
        reject(new Error('Invalid user data'))
      }
    }, 500)
  })
}

// ダミーAPI(ユーザーデータ取得)
export async function fetchUserData(): Promise<User> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id: 1,
        name: 'John Doe',
        email: 'john.doe@example.com',
        settings: {
          theme: 'dark',
          notifications: true,
        },
      })
    }, 500)
  })
}

// ダミーAPI(ユーザーデータ保存)
export async function saveUserData(user: User): Promise<void> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (user.id) {
        resolve()
      } else {
        reject(new Error('Invalid user data'))
      }
    }, 500)
  })
}

model.ts

export interface User {
  id: number
  name: string
  email: string
  settings: {
    theme: string
    notifications: boolean
  }
}

TypeScript graph

https://github.com/ysk8hori/typescript-graph
こちらをPRに導入すると、このファイル分割の方針がかなり見やすくなっておすすめです。ぜひ見てみてください!

おわりに

こんな感じにわけると、見やすいし保守や改善、機能開発がしやすくていいですね。また、AIコードエディターの精度もよくなっていることが経験的にわかります。こんなこともできるように成長したなぁということで、アドベントカレンダーの記事とさせてください!遅れてごめんなさい!

Discussion