Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TypeScript × React コンポーネント設計

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

TypeScript × React コンポーネント設計

TypeScriptを使って、Reactコンポーネントをどう設計すれば再利用しやすく保守しやすくなるかを話します。状態を型でうまく表す方法や、画面表示とデータ処理を分ける考え方を、自社の実例を交えて紹介します。

TypeScript夜話 feat.おたのし味テック会: https://vivion.connpass.com/event/397447/
X: https://x.com/kajitack
TechTrian: http://techtrain.dev/

Avatar for Takuma Kajikawa

Takuma Kajikawa

July 31, 2026

More Decks by Takuma Kajikawa

Other Decks in Programming

Transcript

  1. 梶川 琢馬 𝕏 @kajitack 株式会社 TechBowl VPoT TechTrain の開発 /

    メンター フロントエンドは React / TypeScript、バックエンドも書いてます 先月、オーストラリアのトライアスロンの大会に行ってました。 スライドは X で公開します! x.com/kajitack 2/41
  2. 前提とゴール 前提 React / TypeScript での web フロントエンド開発 全体的なアーキテクチャではなく、 コンポーネント単位の抽象化

    網羅的な内容というより使ってるなーって思う方法を紹介 もっと良い方法あったら教えてください ゴール 再利用性を意識した React コンポーネントの設計を知る 6/41
  3. TypeScriptの恩恵を受けられる コンポーネントは、マークアップを返す JavaScript 関数。 TypeScript によって、引数(props)と返り値はもちろん、状態や副作用 (hooks) にも 型を 前提とした設計

    が出来る。 データ props と state コンポーネント 関数 操作で state が変わる と再実⾏ クイックスタート – React 「コンポーネントとは、マークアップを返す JavaScript 関数です」 https://ja.react.dev/learn 画⾯ JSX 9/41
  4. 再利用可能なコンポーネントは 実装の詳細を知らなくても良い 契約による設計 再利用に耐える部品は、実装を知らずに使える。利用側が依存するのは 契約 だけ React では props がその契約。型で書けば、誤用は型エラーになり、契約の変更は

    全利用箇所へ伝わる On the Criteria To Be Used in Decomposing Systems into Modules(David L. Parnas、1972)情報隠蔽 Object-Oriented Software Construction 2nd ed.(Bertrand Meyer、1997)契約による設計 10/41
  5. 似ているコンポーネントを共通化する? 通知の⾏ メンタリング確定 明⽇20 00の予約が確定 メッセージの⾏ 3分前 ⼭⽥メンター レビューしました 21

    04 2 新しい教材の公開 昨⽇ React編に課題が追加されました TechTrain運営 ⽉曜 ご参加ありがとうございました 通知機能の都合で変わる (種類の追加、既読の扱い) チャットの都合で変わる (未読の件数、最新メッセージ) 16/41
  6. props にはすでに、違う都合が現れている type NotificationRowProps = { icon: ReactNode; title: string;

    content: string; dateTime: string; // 通知の発生時刻 }; TSX type MessageRowProps = { avatar: ReactNode; roomName: string; lastMessage: string; unreadCount: number; // チャットの都合がもう現れている }; 見た目が同じでも、契約はすでに別々の都合を映している The Wrong Abstraction(Sandi Metz、2016)https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction 17/41
  7. ドメインを含まない UI 部品に切り出す 共通化してよいのは、変更理由が 1 つになる粒度。レイアウトには ドメインの都合が入らない type ListRowProps =

    { leading: ReactNode; title: string; description?: string; trailing?: ReactNode; }; TSX // アイコンでもアバターでも // 時刻でも未読バッジでも 18/41
  8. 共通化したコンポーネントを呼び出す const NotificationRow = (p: NotificationRowProps) => ( <ListRow leading={p.icon}

    title={p.title} description={p.content} trailing={p.dateTime} /> ); TSX const MessageRow = (p: MessageRowProps) => ( <ListRow leading={p.avatar} title={p.roomName} description={p.lastMessage} trailing={<UnreadBadge count={p.unreadCount} />} /> ); ドメインの都合はこの薄い実装に残り、レイアウトの変更は共通の UI コンポーネントで済む 19/41
  9. ロジックと表示の分離 Presentation/Containerパターンやhooksに切り出す。 画⾯ごとに残る state と通信 (hooks) props 共通化できる 表⽰ (props

    から JSX) Presentational and Container Components(Dan Abramov、2015)https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0 21/41
  10. UI パーツは、共通化できる 表示部分は props が 3 つの小さなコンポーネントに統合できた type Props =

    { count: number; isLiked: boolean; onClick: () => void; }; TSX どの画面でも使える。ドメインの情報が props にない 22/41
  11. 同じ理由で変わるものを集め、 違う理由で変わるものを分ける 単一責任原則 表示 ロジック デザインの都合で変わる ドメインの都合で変わる props と表示用の state

    で描画する 通信と業務ルールが残る 変わる理由が違うものを分けておくと、 互いに独立して変更できる The Single Responsibility Principle (Robert C. Martin, 2014) https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html 24/41
  12. 判断を個人の心がけにせず、仕組みで支える アプリ 4本 import は公開層のみ 公開層 pages と widgets 内部層

    patterns と primitives 安定したら昇格 デザインシステム 約30コンポーネント 層の依存の向きは、規約文書ではなく CI が検査する dependency-cruiser(約 20 ルール) 26/41
  13. 共通コンポーネントは 放っておくと props が増え続ける コンポーネント内部で全部チェックする...? 原因は、呼び出し側に許す 自由度 を決めていないこと リリース時 半年後

    CardProps title: string onClick: () => void variant: Variant CardProps title: string onClick: () => void variant: Variant showBadge?: boolean isCompact?: boolean size?: Size 1年後 CardProps title: string onClick: () => void variant: Variant showBadge?: boolean isCompact?: boolean hideFooter?: boolean size?: Size prefix?: ReactNode suffix?: ReactNode className?: string onLongPress?: () => void どの組み合わせが正しいのか、誰にも分からない 28/41
  14. variant で必須の props が変わるなら、直和で書く variant を指定した時点で、渡せる props が決まる type Props

    = | { variant: "completed"; score: number; onRetry: () => void } | { variant: "notStarted"; onStart: () => void }; TSX 31/41
  15. 型が許す集合を、あり得る状態に一致させる Make illegal states unrepresentable オプションの props が許す組み合わせ 実際にあり得る状態 ×

    variant: notStarted + score あり (あり得ない) variant: completed + score あり variant: notStarted + score なし × variant: completed + score なし (あり得ない) 直和で書くと、型の集合がこの内側の楕円と⼀致する 32/41
  16. 描画する要素ごと、差し替えを許す Polymorphic Components type TextProps<E extends ElementType = "span"> =

    { as?: E; } & Omit<ComponentPropsWithoutRef<E>, "as">; TSX <Text as="h2">見出しとして描画</Text> <Button as="a" href="/pricing">ボタンの見た目のリンク</Button> props の型が as に連動する。as="a" なら href が許され、span なら型エラー 33/41
  17. 呼び出し側が変更できる範囲を明示する ハンドラと活性状態は画面の自由。見た目はこちらで決める type Props = { actionButton?: Pick<ButtonProps, "onClick" |

    "disabled">; }; TSX 呼び出し側が色を変えようとすれば型エラーになる。型自体もコピーで再定義せず、元の 型から導出して再利用する 34/41
  18. 同時に渡せない props は never で排他にする 「A と B はどちらか片方だけ」というコメントは守られない type

    Props = | { href: string; onClick?: never } | { href?: never; onClick: () => void }; TSX 型に書いた制約だけが、次に触る人まで届く 35/41
  19. 構造の自由が要るときは、 組み立てを利用側に任せる Compound Components <Tabs> <TabList aria-label="コンテンツのタブ"> <TabTrigger value="overview">概要</TabTrigger> <TabTrigger

    value="review">レビュー</TabTrigger> </TabList> <TabContent value="overview">...</TabContent> </Tabs> TSX 「Tabs の内側」という構造は型で守れない。実行時チェックで守る 36/41
  20. 型で書けない契約は、実行時に早く落とす Tabs の子コンポーネントでない場合、エラーになる。 export const useTabsContext = () => {

    const context = useContext(TabsContext) if (!context) { throw new Error('useTabsContext must be used within a TabsProvider') } return context } TypeScript 37/41
  21. 型でパターンを分けるか、 コンポーネントを分けるか 同じ概念の排他状態 別の概念 直和(Discriminated Union)で1つに コンポーネントを分けて、ドメインのない type Props =

    | { variant: "completed"; score: number } | { variant: "notStarted"; onStart: () => void }; 課題カードの完了/未着手 TSX 部品に切り出す // 共通部品 <ListRow leading={...} title={...} /> TSX // ドメイン層 <NotificationRow /> <MessageRow /> 通知の行/メッセージの行 38/41