ReactとVueにおける大規模コンポーネント分割の実践的戦略
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
急速に進化するフロントエンド開発の世界において、ReactとVueは、開発者が複雑でインタラクティブなユーザーインターフェースを構築することを可能にする主要な力として台頭してきました。しかし、アプリケーションの複雑さが増すにつれて、一般的な課題が生じます。それは、大規模でモノリシックなコンポーネントの成長です。これらの「ゴッドコンポーネント」は、しばしば扱いにくく、保守、テスト、再利用が困難になり、開発サイクルの遅延やバグ発生の可能性の増加につながります。この記事は、ReactとVueの大規模コンポーネントを分解するための、実践的かつ効果的な戦略を検討することで、この重大な問題に対処することを目的としています。カスタムフック/コンポーザブルとサブコンポーネントの強力なテクニックを掘り下げ、これらのアーキテクチャパターンが、絡み合ったコードベースをモジュラーで理解しやすくスケーラブルなものにどのように変えることができるかを実証し、最終的に開発者の生産性とアプリケーションの堅牢性を向上させます。
コアコンセプトと戦略
実践的な戦略に飛び込む前に、ReactとVueにおけるコンポーネント分解の基盤を形成するいくつかのコア用語を明確にしましょう。
- コンポーネント (Component): ReactとVueの両方における基本的なビルディングブロックです。UIの一部とその関連ロジックをカプセル化します。
- ステート (State): コンポーネントが内部で管理し、時間とともに変化して再レンダリングを引き起こす可能性のあるデータです。
- プロップス (Props): 親コンポーネントから子コンポーネントに渡される不変のデータで、コミュニケーションと設定を可能にします。
- 副作用 (Side Effects): 外部世界と対話する操作(データ取得、DOM操作、サブスクリプションなど)であり、通常はレンダリング後に処理されます。
カスタムフック (React) / コンポーザブル (Vue)
ReactのカスタムフックとVueのコンポーザブルは、ステートフルなロジックをコンポーネントから再利用可能な関数に抽出するための強力なメカニズムです。これらは、関連するロジック、ステート、副作用をカプセル化し、コンポーネントをよりクリーンでUIレンダリングに集中させることができます。
-
原則: コンポーネント内で繰り返されるロジック、ステート管理、または副作用を特定します。このロジックは、多くの場合、コンポーネントのレンダリング責任には直接関係しません。
-
実装 (React - カスタムフック):
製品データを取得し、ローディングステータスを管理し、お気に入りステータスを処理する大規模な
ProductDetailsコンポーネントを考えてみましょう。// Before: Large ProductDetails component function ProductDetails({ productId }) { const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isFavorite, setIsFavorite] = useState(false); const [error, setError] = useState(null); useEffect(() => { const fetchProduct = async () => { setIsLoading(true); try { const response = await fetch(`/api/products/${productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); setProduct(data); // Assume logic to check if product is favorite setIsFavorite(data.isFavoriteByUser); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; fetchProduct(); }, [productId]); const toggleFavorite = () => { // API call to update favorite status setIsFavorite(prev => !prev); }; if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={toggleFavorite}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* ... more UI elements */} </div> ); }次に、データ取得とお気に入りトグルロジックをカスタムフックに抽出しましょう。
// Custom Hook: useProductData.js import { useState, useEffect } from 'react'; function useProductData(productId) { const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchProduct = async () => { setIsLoading(true); setError(null); try { const response = await fetch(`/api/products/${productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); setProduct(data); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; fetchProduct(); }, [productId]); return { product, isLoading, error, setProduct }; } // Custom Hook: useFavoriteToggle.js import { useState } from 'react'; function useFavoriteToggle(initialIsFavorite) { const [isFavorite, setIsFavorite] = useState(initialIsFavorite); const toggleFavorite = async (productId) => { // productId might be needed for API call // Simulate API call console.log(`Toggling favorite for product ID: ${productId}`); await new Promise(resolve => setTimeout(resolve, 500)); setIsFavorite(prev => !prev); // In a real app, you'd make an actual API call here }; return { isFavorite, toggleFavorite, setIsFavorite }; } // After: Refactored ProductDetails component function ProductDetails({ productId }) { const { product, isLoading, error, setProduct } = useProductData(productId); const { isFavorite, toggleFavorite } = useFavoriteToggle(product?.isFavoriteByUser || false); // Initialize with product's favorite status // Update product's favorite status when isFavorite changes useEffect(() => { if (product) { setProduct(prev => ({ ...prev, isFavoriteByUser: isFavorite })); } }, [isFavorite, product, setProduct]); if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* ... more UI elements */} </div> ); } -
実装 (Vue - コンポーザブル):
同じ製品詳細のシナリオをVue 3のComposition APIで適応させてみましょう。
<!-- Before: Large ProductDetails component --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <!-- ... more UI elements --> </div> </div> </template> <script setup> import { ref, onMounted, watch } from 'vue'; const props = defineProps({ productId: String, }); const product = ref(null); const isLoading = ref(true); const isFavorite = ref(false); const error = ref(null); const fetchProduct = async () => { isLoading.value = true; error.value = null; try { const response = await fetch(`/api/products/${props.productId}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); product.value = data; isFavorite.value = data.isFavoriteByUser; } catch (err) { error.value = err.message; } finally { isLoading.value = false; } }; const toggleFavorite = async () => { // Simulate API call console.log(`Toggling favorite for product ID: ${props.productId}`); await new Promise(resolve => setTimeout(resolve, 500)); isFavorite.value = !isFavorite.value; // In a real app, you'd make an actual API call here }; onMounted(fetchProduct); watch(() => props.productId, fetchProduct); </script>次に、ロジックをコンポーザブルに抽出しましょう。
<!-- useProductData.js --> import { ref, onMounted, watch } from 'vue'; export function useProductData(productIdRef) { // productIdRef is a ref const product = ref(null); const isLoading = ref(true); const error = ref(null); const fetchProduct = async () => { isLoading.value = true; error.value = null; try { const response = await fetch(`/api/products/${productIdRef.value}`); if (!response.ok) throw new Error('Failed to fetch product'); const data = await response.json(); product.value = data; } catch (err) { error.value = err.message; } finally { isLoading.value = false; } }; onMounted(fetchProduct); watch(productIdRef, fetchProduct); return { product, isLoading, error, fetchProduct }; // Expose fetchProduct if needed } <!-- useFavoriteToggle.js --> import { ref } from 'vue'; export function useFavoriteToggle(initialIsFavorite) { const isFavorite = ref(initialIsFavorite); const toggleFavorite = async (productId) => { // productId might be needed for API call console.log(`Toggling favorite for product ID: ${productId}`); await new Promise(resolve => setTimeout(resolve, 500)); isFavorite.value = !isFavorite.value; // Real API call here }; return { isFavorite, toggleFavorite }; } <!-- After: Refactored ProductDetails component --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite(productId)"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <!-- ... more UI elements --> </div> </div> </template> <script setup> import { ref, watchEffect } from 'vue'; import { useProductData } from './useProductData.js'; import { useFavoriteToggle } from './useFavoriteToggle.js'; const props = defineProps({ productId: String, }); const productIdRef = ref(props.productId); // Create a ref from prop for composable const { product, isLoading, error } = useProductData(productIdRef); const { isFavorite, toggleFavorite } = useFavoriteToggle(product.value?.isFavoriteByUser || false); // Watch for product changes and update initialIsFavorite for the composable // This is a common pattern when initial composable state depends on async data watchEffect(() => { if (product.value) { isFavorite.value = product.value.isFavoriteByUser; } }); // If product updates, make sure the favorite status inside product also updates watch(isFavorite, (newVal) => { if (product.value) { product.value.isFavoriteByUser = newVal; } }); </script>
コンポーザブル/カスタムフックは、以下に最適です。
- 再利用可能なロジック(フォーム検証、データ取得、認証など)のカプセル化。
- 複数のソースから派生した複雑なステートの管理。
- コンポーネントのレンダリングロジックを散らかさずに副作用をクリーンに処理する。
子コンポーネント
子コンポーネントは、大規模コンポーネントを分解するためのもう1つの基本的な方法です。ここでの原則は、UIとそれに直接関連するロジックを、視覚的および論理的な境界に基づいて分割することです。
-
原則: コンポーネントのレンダリング結果内の明確なセクションを探します。セクションに独自のステート(最小限であっても)、プロップス、または複雑なレンダリングロジックがある場合、それは子コンポーネントの候補となります。
-
実装 (React):
ProductDetailsの例を続けると、製品詳細ビューにProductReviewセクションとAddToCartButtonも含まれているとしましょう。// Before: All in ProductDetails function ProductDetails({ productId }) { // ... data fetching and favorite toggling logic as before ... if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> {/* Product reviews section directly here */} <h2>Reviews</h2> {product.reviews.length > 0 ? ( <ul> {product.reviews.map(review => ( <li key={review.id}> <strong>{review.author}</strong> - {review.rating}/5 <p>{review.comment}</p> </li> ))} </ul> ) : ( <p>No reviews yet.</p> )} {/* Add to cart section directly here */} <button onClick={() => alert(`Added ${product.name} to cart!`)}> Add to Cart </button> </div> ); }次に、
ProductReviewsとAddToCartButtonを子コンポーネントとして抽出しましょう。// ProductReviews.jsx function ProductReviews({ reviews }) { if (reviews.length === 0) { return <p>No reviews yet.</p>; } return ( <div> <h2>Reviews</h2> <ul> {reviews.map(review => ( <li key={review.id}> <strong>{review.author}</strong> - {review.rating}/5 <p>{review.comment}</p> </li> ))} </ul> </div> ); } // AddToCartButton.jsx function AddToCartButton({ productName, onAddToCart }) { return ( <button onClick={onAddToCart}> Add {productName} to Cart </button> ); } // After: Refactored ProductDetails component function ProductDetails({ productId }) { const { product, isLoading, error } = useProductData(productId); const { isFavorite, toggleFavorite } = useFavoriteToggle(product?.isFavoriteByUser || false); // ... useEffect for syncing favorite state as before ... if (isLoading) return <div>Loading product...</div>; if (error) return <div>Error: {error}</div>; if (!product) return <div>Product not found.</div>; const handleAddToCart = () => { alert(`Added ${product.name} to cart!`); // In a real app, dispatch an action to a cart store }; return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <button onClick={() => toggleFavorite(productId)}> {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} </button> <ProductReviews reviews={product.reviews || []} /> <AddToCartButton productName={product.name} onAddToCart={handleAddToCart} /> </div> ); } -
実装 (Vue):
<!-- Before: All in ProductDetails (similar to React example, but using Vue template syntax) --> <!-- Same as the previous Vue example, but imagine reviews and add to cart logic directly in its template --> <!-- ProductReviews.vue --> <template> <div> <h2>Reviews</h2> <ul v-if="reviews.length > 0"> <li v-for="review in reviews" :key="review.id"> <strong>{{ review.author }}</strong> - {{ review.rating }}/5 <p>{{ review.comment }}</p> </li> </ul> <p v-else>No reviews yet.</p> </div> </template> <script setup> defineProps({ reviews: { type: Array, default: () => [], }, }); </script> <!-- AddToCartButton.vue --> <template> <button @click="handleClick"> Add {{ productName }} to Cart </button> </template> <script setup> const props = defineProps({ productName: String, }); const emit = defineEmits(['add-to-cart']); const handleClick = () => { emit('add-to-cart', props.productName); }; </script> <!-- After: Refactored ProductDetails component --> <template> <div> <div v-if="isLoading">Loading product...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="!product">Product not found.</div> <div v-else> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <button @click="toggleFavorite(productId)"> {{ isFavorite ? 'Remove from Favorites' : 'Add to Favorites' }} </button> <ProductReviews :reviews="product.reviews || []" /> <AddToCartButton :product-name="product.name" @add-to-cart="handleAddToCart" /> </div> </div> </template> <script setup> import { ref, watchEffect } from 'vue'; import { useProductData } from './useProductData.js'; import { useFavoriteToggle } from './useFavoriteToggle.js'; import ProductReviews from './ProductReviews.vue'; import AddToCartButton from './AddToCartButton.vue'; const props = defineProps({ productId: String, }); const productIdRef = ref(props.productId); const { product, isLoading, error } = useProductData(productIdRef); const { isFavorite, toggleFavorite } = useFavoriteToggle(product.value?.isFavoriteByUser || false); watchEffect(() => { if (product.value) { isFavorite.value = product.value.isFavoriteByUser; } }); watch(isFavorite, (newVal) => { if (product.value) { product.value.isFavoriteByUser = newVal; } }); const handleAddToCart = (productName) => { alert(`Added ${productName} to cart!`); // Dispatch action to cart store }; </script>
子コンポーネントは、以下に効果的です。
- 複雑なUIブロックを抽象化することによって、可読性を向上させる。
- アプリケーションのさまざまな部分にわたるUI要素の再利用を可能にする。
- レンダリングパフォーマンスの最適化(プロップスが変更されていない場合、子コンポーネントの不要な再レンダリングを防ぐReactの
memoまたはVueの固有のリアクティビティ)。 - 各コンポーネントが明確な責任を持つように、関心の分離を強制する。
どちらを使うべきか?
- カスタムフック/コンポーザブルは、ロジックの再利用とデータおよびビヘイビアに関連する関心の分離のためです。これらは直接何もレンダリングしませんが、ステートと関数を提供します。
- 子コンポーネントは、UIの再利用とプレゼンテーションに関連する関心の分離のためです。これらは、視覚的インターフェースの一部をカプセル化します。
多くの場合、両方を組み合わせて使用します。子コンポーネントは、カスタムフック/コンポーザブルを内部ロジックに使用して、モジュール性をさらに強化することがあります。
結論
カスタムフック/コンポーザブルと子コンポーネントを使用して、ReactおよびVueの大きなコンポーネントを小さく、焦点を絞ったユニットに分解することは、単なるスタイル上の選択ではありません。それは、保守可能でスケーラブルで高品質なフロントエンドアプリケーションを構築するための基本的な実践です。これらの戦略を一貫して適用することにより、開発者は、よりクリーンなコード、デバッグの容易さ、再利用性の向上、チーム内のコラボレーションの強化を達成でき、最終的にはより堅牢で快適なユーザーエクスペリエンスにつながります。これらの分解テクニックをマスターして、モノリシックなコンポーネントを調和のとれたモジュラーパーツのオーケストラに変えましょう。

