カスタムフックを用いたリファクタリング
前の章でテストを書いているので、自身を持ってリファクタリングが可能
リファクタリング前(一部の型は省略)
code:Carousel.tsx
const Carousel = ({
slides,
defaultImgHeight,
DefaultImgComponent,
}: CarouselProps) => {
return (
<div data-testid="carousel">
<CarouselSlide
imgHeight={defaultImgHeight}
ImgComponent={DefaultImgComponent}
/>
<CarouselButton
data-testid="prev-button"
onClick={() => {
if (!slides) return;
setSlideIndex((i) => (i + slides.length - 1) % slides.length);
}}
Prev
</CarouselButton>
<CarouselButton
data-testid="next-button"
onClick={() => {
if (!slides) return;
setSlideIndex((i) => (i + 1) % slides.length);
}}
Next
</CarouselButton>
</div>
);
};
useState 部分をカスタムフックに切り出す
code:useSlideIndex.tsx
import { useState } from "react";
export const useSlideIndex = () => {
};
code:Carousel.tsx
const Carousel = ({
slides,
defaultImgHeight,
DefaultImgComponent,
}: CarouselProps) => {
return (
<div data-testid="carousel">
/** ... */
</div>
);
};
イベントハンドラロジックの移植
onClick 部分のロジックを useSlideIndex に移植する
code:useSlideIndex.tsx
export const useSlideIndex = (slides?: unknown[]) => {
const decrementSlideIndex = () => {
if (!slides) return;
setSlideIndex((i) => (i + slides.length - 1) % slides.length);
};
const incrementSlideIndex = () => {
if (!slides) return;
setSlideIndex((i) => (i + 1) % slides.length);
};
};
配列であればどんな型でも良いので、パラメータの型を unknown[] にしている
code:Carousel.tsx
const Carousel = ({
slides,
defaultImgHeight,
DefaultImgComponent,
}: CarouselProps) => {
return (
<div data-testid="carousel">
<CarouselSlide
imgHeight={defaultImgHeight}
ImgComponent={DefaultImgComponent}
/>
<CarouselButton data-testid="prev-button" onClick={decrementSlideIndex}>
Prev
</CarouselButton>
<CarouselButton data-testid="next-button" onClick={incrementSlideIndex}>
Next
</CarouselButton>
</div>
);
};
Controllable にするために以下のように実装する
slideIndex props が指定されていないケース
Prev や Next ボタンが押されたときに、内部状態の slideIndex を更新する(これまで同じ)
slideIndex props が指定されているケース
渡された値で内部状態を上書きする
onSlideIndeChange props も受け取る
この関数には、渡された slideIndex を更新する処理を記述する
テストの拡充
code:Carousel.test.tsx
describe("with controlled slideIndex", () => {
const onSlideIndexChange = vi.fn();
const renderCarouselWithSlideIndex = () =>
render(
<Carousel
slides={slides}
slideIndex={1}
onSlideIndexChange={onSlideIndexChange}
/>,
);
beforeEach(() => {
onSlideIndexChange.mockReset();
});
it("shows the slide corresponding to slideIndex", () => {
renderCarouselWithSlideIndex();
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", slides1.imgUrl); });
it("calls onSlideIndexChange when Prev is clicked", async () => {
renderCarouselWithSlideIndex();
const img = screen.getByRole("img");
const prevButton = screen.getByTestId("prev-button");
const user = userEvent.setup();
await user.click(prevButton);
// no change because onSlideIndexChange is mocked
expect(img).toHaveAttribute("src", slides1.imgUrl); expect(onSlideIndexChange).toHaveBeenCalledWith(0);
});
it("calls onSlideIndexChange when Next is clicked", async () => {
renderCarouselWithSlideIndex();
const img = screen.getByRole("img");
const prevButton = screen.getByTestId("next-button");
const user = userEvent.setup();
await user.click(prevButton);
// no change because onSlideIndexChange is mocked
expect(img).toHaveAttribute("src", slides1.imgUrl); expect(onSlideIndexChange).toHaveBeenCalledWith(2);
});
});
カスタムフックの修正
code:useSlideIndex.tsx
const decrement = (length: number) => (i: number) => (i + length - 1) % length;
const increment = (length: number) => (i: number) => (i + 1) % length;
export const useSlideIndex = (
slides?: unknown[],
slideIndexProp?: number,
onSlideIndexChange?: (newSlideIndex: number) => void,
) => {
const slideIndex = slideIndexProp ?? slideIndexState;
const decrementSlideIndex = () => {
if (!slides) return;
setSlideIndexState(decrement(slides.length));
onSlideIndexChange?.(decrement(slides.length)(slideIndex));
};
const incrementSlideIndex = () => {
if (!slides) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
};
};
コンポーネントの修正
code:Carousel.tsx
export type CarouselProps = {
slides?: Slide[];
slideIndex?: number;
onSlideIndexChange?: (newSlideIndex: number) => void;
};
const Carousel = ({
slides,
slideIndex: slideIndexProp,
onSlideIndexChange,
defaultImgHeight,
DefaultImgComponent,
}: CarouselProps) => {
slides,
slideIndexProp,
onSlideIndexChange,
);
// ...
};