こんにちは。Findyでモバイルアプリ開発を担当している加藤と主計です。
Findy初のモバイルアプリ「Findy Events」については、先日React Native選定の経緯と立ち上げの全体像を公開しました。
前記事ではUIライブラリ周りには深く踏み込めなかったので、今回はその続編です。UIライブラリの選定と実装パターンに絞ってお届けします。
具体的には、当初採用していたTamaguiからHeroUI Native + react-native-unistylesに乗り換えるまでの判断と、Wrapper Componentを軸にしたUI構築の進め方が中心です。
なお、本記事で題材にしているFindy Eventsは、App StoreとGoogle Playで公開しています。
- Tamagui採用の背景
- Tamaguiを使ってみて見えてきた課題
- HeroUI Native + react-native-unistylesに変更を決めた理由
- β版採用のリスクとその対策方針
- 具体的なコード例
- まとめ
Tamagui採用の背景
Findyのモバイルアプリ開発で、当初UIライブラリとして採用していたのはTamagui。
選定時の候補はgluestack-uiとTamaguiの2つで、最終的にTamaguiを選びました。
決め手となった理由は大きく3つあります。
1つ目は、ライブラリとしての信頼性と情報量です。
Tamaguiはv0.1.0が2021年3月にリリースされ、5年以上にわたって開発が続いているOSSです。
GitHubのスター数や提供されるComponent数の充実度から、本番プロダクトに採用しても大きく外さないと判断しました。歴史がある分、ネット上の記事も比較的多く、開発時の調査コストを抑えやすい点も後押し材料です。
2つ目は、iOSエンジニアとしての開発経験との親和性です。
iOS開発ではOSSとして提供されたUIをそのまま利用する、あるいは許容範囲で改変するアプローチが一般的で、Tamaguiはこのメンタルモデルに合致していました。
SwiftUIと同じ宣言的UIの思想を持ち、縦方向に並べるSwiftUIのVStackがTamaguiのYStackに対応するように、子要素を軸方向に積み上げてレイアウトを組み立てる考え方が共通している点も馴染みやすいポイントです。
一方のgluestack-uiは、shadcn/uiと同様にCLIでComponentのコードを自分のリポジトリに生成し、必要に応じて編集して使う方式を採っています。Tailwindライクなクラス指定と合わせて、モバイルエンジニア出身の自分にはやや取っ付きにくく感じました。
3つ目は、Propsベースで直感的にスタイリングできることです。
<Stack m="$2" p="$2" backgroundColor="$background">のように、Componentに対してProps経由でデザイントークンを直接当てられ、感覚としては昔ながらのCSSやSwiftUIのmodifierに近い書き味で扱えます。
デザイントークンによるテーマ管理でライト/ダークテーマの切り替えが容易な点も決め手になりました。
Tamaguiを使ってみて見えてきた課題
Tamaguiを採用してアプリを開発する中で、幾つか見えてきた課題もありました。HeroUI Nativeへの乗り換えを判断した背景でもあるため、ここで率直に共有します。
Android実機固有の挙動
iOSシミュレータやAndroidエミュレータでは問題ないものの、Android実機でのみ起きる挙動に幾つかぶつかりました。ここでは印象に残った2つのケースを、実コードと合わせて紹介します。
Sheet表示直後のタップが効かない
メニューや確認ダイアログとしてSheetを表示した直後に、内部のボタンをタップしても反応しないことがありました。
最初はダブルタップが必要なのかと感じましたが、少し時間をおくと反応するため、UIアニメーションの完了までタップが受け付けられていないと推測しました。デフォルトのアニメーションは完了までに時間がかかり、その間タップを取りこぼしていたようです。
そこでSheetにanimation="200ms"のように短い時間のアニメーションを明示的に指定したところ、表示完了までの時間が短くなり、表示直後のタップにもすぐ反応するようになって解消しました。
<Sheet modal open={open} onOpenChange={setOpen} snapPoints={[50]} animation="200ms" // 明示しないとAndroid実機で表示直後のタップが効かない > <Sheet.Overlay /> <Sheet.Frame padding="$4"> <Button onPress={handleConfirm}>確定する</Button> </Sheet.Frame> </Sheet>
Sheet内のScrollViewでスクロールするとSheetが閉じる
選択肢が多い項目を選ばせるUIとして、選択肢一覧をScrollViewで表示したSheetを出していたのですが、Pixel 10で動作確認していたところ、Sheet内をスクロールしようとするとそのままSheetごと閉じてしまう事象が発生しました。
該当箇所はおおよそ次のような構成です。
import { ScrollView } from "react-native"; import { Sheet } from "tamagui"; <Sheet modal open={open} onOpenChange={setOpen} snapPoints={[80]}> <Sheet.Overlay /> <Sheet.Frame> <ScrollView> {options.map((option) => ( <Option key={option.id} option={option} /> ))} </ScrollView> </Sheet.Frame> </Sheet>
Sheet内のScrollViewでのスクロール操作が、Sheetを閉じるためのドラッグジェスチャと衝突しているのが原因と推測しています。
暫定対応としてSheet側にdisableDragを指定したところ、スクロール時にSheetが閉じる事象は解消されました。
<Sheet modal open={open} onOpenChange={setOpen} snapPoints={[80]} disableDrag // ドラッグでSheetを閉じる挙動を無効化し、ScrollViewのスクロールと衝突しないようにする > <Sheet.Overlay /> <Sheet.Frame> <ScrollView> {options.map((option) => ( <Option key={option.id} option={option} /> ))} </ScrollView> </Sheet.Frame> </Sheet>
ただしdisableDragを指定すると、本来ドラッグで閉じられるはずのSheetが閉じられなくなり、UX上の妥協が発生してしまいます。
Sheet周りはAndroid実機での挙動が安定しないケースが続いたこともあり、根本的にはTamaguiのSheet自体の利用を見直す必要があると感じはじめ、後にHeroUI Nativeへの乗り換えを検討する一因にもなりました。
Expo SDK 54対応のタイミング
ExpoはReact Nativeでクロスプラットフォームのアプリを開発するためのフレームワークで、モバイルアプリの証明書周りを簡単に扱える仕組みや、様々な便利機能をSDKとして提供しています。そのExpo SDK 54は2025年9月11日にリリースされ、TamaguiのGitHub上でも対応に向けたWIP Pull Requestや議論が動いていました。
一方で、Tamagui側の対応版がリリースされたのは2025年11月15日で、対応版リリース日のアナウンスは事前にはなかったと記憶しています。
5年以上の歴史を持つライブラリで、内部に多くの実装を抱えている分、最新Expo SDKへの追従は簡単ではないのだろうと推測しています。OSSライブラリを採用する以上、こうした追従コストとはトレードオフだと改めて感じる出来事でした。
HeroUI Native + react-native-unistylesに変更を決めた理由
ここまで見てきた課題を踏まえ、次のUI基盤としてHeroUI Nativeとreact-native-unistylesを組み合わせる構成を選びました。
HeroUI Nativeを選んだ理由は、Componentの完成度が高く、画面実装のスピードを上げられると考えたためです。デフォルトの見た目が洗練されており、アニメーションやインタラクションも作り込まれているため、細かい調整を加えなくてもそのまま画面を組み立てられます。Findy Eventsはまだ機能を増やしていく段階にあり、必要な画面を素早く形にできることは、UIライブラリに強く求めていた点でした。
スタイリングにreact-native-unistylesを採用したのは、スタイル定義を標準のAPIに寄せたかったためです。HeroUI Native自体は内部のスタイリングにUniwind(Tailwind v4ベースの仕組み)を使っていますが、プロダクト側のスタイルは、React Native標準のStyleSheet APIに近い書き味で扱いたいと考えました。react-native-unistylesならStyleSheet.createを起点に、色やfontSize、spacing、radiusといったトークンを定義でき、モバイルエンジニアにとっても馴染みやすい書き味になります。
β版採用のリスクとその対策方針
一方で、HeroUI Nativeの採用にはリスクもありました。
検討していた当時のHeroUI Nativeはまだβ版段階のライブラリでした。正式版のv1.0.0は2026年3月19日にリリースされ、Findy Eventsのアプリリリース時点ではv1.0系に到達しています。ただ、採用を判断した時点ではまだ正式版前のライブラリです。β版段階のライブラリを本番プロダクトの土台に据える以上、特に破壊的変更には注意が必要でした。APIがまだ固まりきっておらず、バージョンアップのたびに利用側のコード修正を迫られる可能性があるためです。
そこで、HeroUI NativeのComponentは画面から直接使わず、必ずWrapper Componentを挟んで利用する方針にしました。Wrapper Componentという境界を1枚挟むことで、HeroUI Native側のAPIが変わっても、修正をPrimitive層に閉じ込められます。
この方針は、破壊的変更への備えであると同時に、将来的にHeroUI NativeのComponentを自分たちの独自実装へ切り替えたくなった場合にも効きます。利用側はWrapper Component経由でしかComponentに触れていないため、内側の実装をHeroUI Nativeから独自実装へ差し替えても、画面側のコードに影響を出さずに移行できます。
具体的なコード例
ここでは、HeroUI NativeのComponentをWrapper Componentとして定義し、react-native-unistylesと組み合わせて画面を構築するまでの流れを紹介します。
HeroUI NativeのWrapper Component
まず、最もシンプルな例として、SkeletonのWrapper Componentのコードを見ていきましょう。
// src/components/primitives/skeleton/skeleton.component.tsx import type { SkeletonProps } from "heroui-native"; import type { PropsWithChildren } from "react"; import { Skeleton as HeroUISkeleton } from "heroui-native"; type Props = PropsWithChildren< Pick<SkeletonProps, "isLoading" | "style"> >; export const Skeleton = ({ isLoading = true, style, children }: Props) => { return ( <HeroUISkeleton isLoading={isLoading} variant="shimmer" style={style}> {children} </HeroUISkeleton> ); };
HeroUI NativeのComponentは多くのPropsを持っていますが、アプリ内で実際に使うPropsは限られます。
Wrapper Componentを定義する時に、Pick<>で必要なPropsだけを公開することで、利用側のインターフェースをシンプルに保つことができます。
また、variantのようにアプリ全体で固定したい設定値はWrapper Component内で埋め込むことで、利用側が意識する必要がなくなります。
次に、もう少し複雑な例として、ButtonのWrapper Componentのコードを見てみましょう。
HeroUI NativeにはButton.LabelやCard.Bodyのように、サブComponentを持つCompound Componentがあります。
ラップした後もButton.Labelのような呼び出し方を維持したい場合は、Object.assign()を使って親ComponentにサブComponentを紐付けます。
// src/components/primitives/button/button.component.tsx import type { PropsWithChildren } from "react"; import { Button as HeroUIButton } from "heroui-native"; import type { ButtonLabelProps, ButtonRootProps } from "heroui-native"; import { StyleSheet } from "react-native-unistyles"; export type ButtonComponentProps = PropsWithChildren< Pick< ButtonRootProps, "style" | "size" | "onPress" | "isDisabled" | "animation" > >; export type ButtonLabelComponentProps = PropsWithChildren< Pick<ButtonLabelProps, "style"> >; const ButtonRoot = ({ children, style, size, onPress, isDisabled, animation, }: ButtonComponentProps) => { return ( <HeroUIButton style={style} size={size} onPress={onPress} isDisabled={isDisabled} animation={animation} > {children} </HeroUIButton> ); }; const ButtonLabel = ({ children, style }: ButtonLabelComponentProps) => { return ( <HeroUIButton.Label style={style}> {children} </HeroUIButton.Label> ); }; export const Button = Object.assign(ButtonRoot, { Label: ButtonLabel, });
このようにButton.Labelの形でアクセスできるため、利用側のコードはHeroUI Nativeの元のAPIと同じ使い心地を維持できます。
react-native-unistylesとWrapper Componentを組み合わせたUI Componentの実装
続いて、定義したWrapper Componentをreact-native-unistylesと組み合わせて、実際の画面で利用するUI Componentを作る例を紹介します。
まず、カラートークンを定義したファイルを用意します。
// src/styles/generated/primitive-colors.ts export const primitiveColors = { black: "#000000", white: "#ffffff", ... blue400: "#377ecd", blue500: "#055ec1", blue600: "#044b9a", ... } as const; export type PrimitiveColors = typeof primitiveColors;
次に、react-native-unistylesで、テーマに沿ったスタイリングを効率的に行えるように、StyleSheet.configureでアプリ全体のテーマトークンを定義します。
本アプリでは、色やフォントサイズ、spacing、角丸などをトークンとして管理しており、Componentごとの配色もここに集約しています。
// src/styles/index.ts import { StyleSheet } from "react-native-unistyles"; import { primitiveColors } from "./generated/primitive-colors"; const tokens = { colors: primitiveColors, fontSize: { xs: 11, sm: 12, md: 14, lg: 16, xl: 20, "2xl": 22 }, radius: { xs: 2, sm: 4, md: 6, lg: 8, xl: 12 }, space: { "2xs": 4, xs: 8, sm: 12, md: 16, lg: 24 }, } as const; const lightTheme = { // グローバル定義 background: primitiveColors.white, backgroundPress: primitiveColors.grey50, ... // Componentごとの定義 button: { danger: { background: primitiveColors.red500, color: primitiveColors.white, backgroundPress: primitiveColors.red600, }, }, fontSize: tokens.fontSize, radius: tokens.radius, space: tokens.space, } as const; const darkTheme = { ... } StyleSheet.configure({ themes: { light: lightTheme, dark: darkTheme }, });
StyleSheet.createのコールバック関数からこのテーマトークンにアクセスでき、さらにスタイル定義自体を関数にすることで、Propsの値に応じた動的なスタイル切り替えも実現できます。
これを踏まえて、先程のButton Wrapper Componentをカスタマイズした「DangerButton」UI Componentの例を見てみましょう。
// src/components/buttons/danger-button/danger-button.component.tsx import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Button } from "@/components/primitives/button"; import type { ButtonComponentProps, ButtonLabelComponentProps, } from "@/components/primitives/button"; const DangerButtonRoot = ({ children, style, ...rest }: ButtonComponentProps) => { const { theme } = useUnistyles(); return ( <Button style={[styles.root, style]} animation={{ highlight: { backgroundColor: { value: theme.button.danger.backgroundPress }, }, }} {...rest} > {children} </Button> ); }; const DangerButtonLabel = ({ children, style }: ButtonLabelComponentProps) => { return <Button.Label style={[styles.label, style]}>{children}</Button.Label>; }; export const DangerButton = Object.assign(DangerButtonRoot, { Label: DangerButtonLabel, }); const styles = StyleSheet.create((theme) => ({ root: { backgroundColor: theme.button.danger.background, }, label: { color: theme.button.danger.color, }, }));
このように、HeroUI NativeのclassNameなどを隠蔽し、プロダクト固有のスタイル定義をreact-native-unistylesで与えており、HeroUI Nativeとreact-native-unistylesで責務を分割しています。
まとめ
本記事では、Findy Eventsの開発で当初採用していたTamaguiからHeroUI Native + react-native-unistylesへ乗り換えるまでの判断と、Wrapper Componentを軸にしたUI実装パターンを紹介しました。
振り返って改めて感じたのは、UIライブラリの選定は「一度決めて終わり」ではなく、運用しながら継続的に見直す前提で向き合うべきだということです。
Tamaguiは選定時の評価としては妥当な候補で、採用後も多くの場面で十分に機能していました。一方で、Android実機特有の挙動やExpo SDK追従までのタイムラグなど、プロダクトとして長く育てる上で無視できない課題も見えてきます。β版というタイミングを活かしてHeroUI Native + react-native-unistylesに踏み込んだ判断は、開発体験の改善という形で素直に効いています。
実装面では、HeroUI NativeをそのままUI Componentとして使うのではなく、Wrapper Component経由で型・Propsを絞り、スタイル定義はreact-native-unistylesに寄せる責務分離が機能しました。プロダクト固有のテーマや配色をWrapper Component側で受け止めることで、UIライブラリ更新の影響範囲を最小化しつつ、画面側のコードもシンプルに保てています。
Findy初のモバイルアプリということで、技術選定もアーキテクチャも手探りで進めた部分が多くありますが、その都度の判断と振り返りそのものが大きな資産になっていると感じます。本記事の経験談が、同じような選定や設計に向き合っている方の判断材料になれば嬉しいです。
ファインディでは一緒に働くメンバーを募集しています。少しでも興味を持っていただけた方は、ぜひこちらをご覧ください!