20210724のReactに関する記事は10件です。

テスト駆動開発(TDD)でポモドーロタイマー制作【React + TypeScript】

テスト駆動開発で簡単なポモドーロタイマーを作ってみます。 今回は内部の処理は気にせず、React Testing Libraryを使った描画のテストを行っていきます。 途中経過と最終的なコードをGitHubにも上げています。 要件 作業時間は25分、休憩時間は5分。 ※長い休憩は実装しない。 タイマーの残り時間がテキストで表示される(MM:SS形式)。 最初は作業時間がセットされている。 開始/停止ボタンが表示される。 タイマーが停止しているときは「開始」、タイマーが作動しているときは「停止」と表示される。最初はタイマーは停止している。 作業/休憩がテキストで表示される。 タイマーが停止しているときと作業中は「作業」、休憩中は「休憩」と表示される。 開始ボタンを押すと、タイマーのカウントが開始される。カウントは1秒ずつ減っていく。 タイマーのカウント時に残り時間が0の場合、作業中の場合は休憩に切り替わり、残り時間に休憩時間がセットされ、休憩中の場合は作業に切り替わり、残り時間に作業時間がセットされる。 停止ボタンを押すと、休憩中の場合は作業に切り替わり、残り時間に作業時間がセットされ、タイマーが止まる。 Reactプロジェクトの作成と起動 React + TypeScriptのプロジェクトを、Create React Appで作成します。 依存関係とバージョン package.json(一部) "dependencies": { "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/jest": "^26.0.15", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", "react-scripts": "4.0.3", "typescript": "^4.1.2", "web-vitals": "^1.0.1" }, npx create-react-app tdd-pomodoro-react --template typescript cd tdd-pomodoro-react 開発サーバーを起動します。コードを書き換えると自動で反映されます。 npm start 別タブでテストを実行します。コードを書き換えると自動で再テストされます。 npm test 今回編集するファイルは、src/App.tsxとsrc/App.test.tsxのみです。 まずはApp.tsxの不要な記述を削除し、白紙のページにします。 App.tsx import React from "react"; const App = () => { return <></>; }; export default App; 初期表示のテスト 次に、App.test.tsxに、初期表示のテストのtodoを追加します。 ページ上に描画される要素は3つなので、それぞれが描画されているかのテストを書いていきます。 App.test.tsx import React from "react"; import { render, screen } from "@testing-library/react"; import App from "./App"; describe("初期表示", () => { test.todo("「25:00」が描画されていること"); test.todo("「開始」が描画されていること"); test.todo("「作業」が描画されていること"); }); 1つ目のtodoを書き換え、タイマーのカウントの初期表示である「25:00」が描画されていることのテストを書きます。 App.test.tsx import React from "react"; import { render } from "@testing-library/react"; import App from "./App"; describe("初期表示", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test.todo("「開始」が描画されていること"); test.todo("「作業」が描画されていること"); }); テストはもちろん失敗です。 次に、App.tsxを編集し、このテストを通過するように最速で実装します。 テストIDが「timeLeft」の要素に「25:00」というテキストがあれば良いので、以下のようになります。 App.tsx import React from "react"; const App = () => { return ( <> <div data-testid="timeLeft">25:00</div> </> ); }; export default App; これでテストを通過しました。 ブラウザにも「25:00」と表示されています。 ただし、このように値を直書きしたままではカウントに応じて値を変えることができないので、リファクタリングを行います。 残り時間はstateに秒で保持するようにして、MM:SS形式に変換する関数を通して画面に表示します。 App.tsx import React, { useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; interface State { timeLeft: number; } /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, }); return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> </> ); }; export default App; 再度テストを通過していることを確認し、リファクタリングを終了します。 次に、開始ボタンが描画されていることのテストを書きます。 App.test.tsx import React from "react"; import { render } from "@testing-library/react"; import App from "./App"; describe("初期表示", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「開始」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timerButton").textContent).toEqual("開始"); }); test.todo("「作業」が描画されていること"); }); テストに失敗していることを確認した後、実装を行います。 テストIDが「timerButton」で「開始」と書かれているボタンを追加します。 App.tsx(一部) return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton">開始</button> </> ); テストを通過していることを確認した後、リファクタリングを行います。 タイマーか作動しているかどうかをboolean型でstateに保持し、その値に応じて「停止」か「開始」と表示するようにします。 App.tsx import React, { useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; interface State { timeLeft: number; isTimerOn: boolean; } /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, }); return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton"> {state.isTimerOn ? "停止" : "開始"} </button> </> ); }; export default App; 再度テストを通過していることを確認し、リファクタリングを終了します。 次に、「作業」が描画されていることのテストを書きます。 App.test.tsx import React from "react"; import { render } from "@testing-library/react"; import App from "./App"; describe("初期表示", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「開始」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timerButton").textContent).toEqual("開始"); }); test("「作業」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); テストに失敗していることを確認した後、実装を行います。 テストIDが「timerMode」の要素に「作業」というテキストがあれば良いので、以下のようになります。 App.tsx(一部) return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton"> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode">作業</div> </> ); テストを通過していることを確認した後、リファクタリングを行います。 タイマーモード(作業"work"または休憩"break")をstateに保持し、その値に応じて「作業」か「休憩」と表示するようにします。 App.tsx import React, { useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; /** タイマーモード */ type TimerMode = "work" | "break"; interface State { timeLeft: number; isTimerOn: boolean; timerMode: TimerMode; } /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, timerMode: "work", }); return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton"> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode"> {state.timerMode === "work" ? "作業" : "休憩"} </div> </> ); }; export default App; 再度テストを通過していることを確認し、リファクタリングを終了します。 ブラウザの表示は以下のようになります。 開始ボタンを押した後の表示のテスト ユーザーが行えるアクションは開始/停止ボタンを押すことだけなので、「開始ボタンを押した後」と「停止ボタンを押した後」の2パターンに分けてテストを書いていきます。 開始ボタンを押した後の表示のテストで最低限必要そうなものをtodoに書き出すと、以下のようになりました。 App.test.tsx(一部) describe("開始ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押した直後の表示のテスト", () => { test.todo("「25:00」が描画されていること"); test.todo("「停止」が描画されていること"); test.todo("「作業」が描画されていること"); }); describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => { test.todo("「25:00」が描画されていること"); }); describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => { test.todo("「24:59」が描画されていること"); }); describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => { test.todo("「24:58」が描画されていること"); }); describe("開始ボタンを押してから25分後の表示のテスト", () => { test.todo("「00:00」が描画されていること"); test.todo("「作業」が描画されていること"); }); describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => { test.todo("「04:59」が描画されていること"); test.todo("「休憩」が描画されていること"); }); describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => { test.todo("「24:59」が描画されていること"); test.todo("「作業」が描画されていること"); }); }); 開始ボタンを押した直後の表示のテスト まずは、開始ボタンを押した直後の表示のテストを書いてみます。 App.test.tsx(一部) describe("開始ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押した直後の表示のテスト", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「停止」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerButton").textContent).toEqual("停止"); }); test("「作業」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); ... テストの結果を見ると、「停止」が描画されていることで失敗していました。 App.tsxを編集し、開始ボタンを押すと「停止」と表示されるようにします。 ボタンをクリックするとonButtonClick関数が呼び出されるようにし、stateのisTimerOnの真偽値を反転させます。 App.tsx(一部) const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, timerMode: "work", }); const onButtonClick = () => { setState((state) => { return { ...state, isTimerOn: !state.isTimerOn }; }); }; return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton" onClick={onButtonClick}> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode"> {state.timerMode === "work" ? "作業" : "休憩"} </div> </> ); }; テストを通過していることを確認できました。 ブラウザで動作を確認すると、ボタンを押すたびに「開始」と「停止」が切り替わっています。タイマーのカウントは進みません。 リファクタリングは必要なさそうなので、次に進みます。 開始ボタンを押してから25分後までの表示のテスト 続いては、開始ボタンを押してから25分後までの表示のテストを一気に書きます。 Jestのタイマーモックを使い、時間経過をコントロールします。 App.test.tsx(一部) ... describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => { test("「25:00」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(999); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => { test("「24:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:59"); }); }); describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => { test("「24:58」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:58"); }); }); describe("開始ボタンを押してから25分後の表示のテスト", () => { test("「00:00」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(25 * 60 * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("00:00"); }); test("「作業」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(25 * 60 * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); ... テストの結果は以下のようになりました。 タイマーのカウントを減らす処理はまだ実装していないので、カウントの描画のテストに失敗しています。 App.tsxを編集し、タイマーのカウントを1秒ごとに減らす処理を実装します。 App.tsx(一部) const onButtonClick = () => { setState((state) => { setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: !state.isTimerOn }; }); }; const timerCount = () => { setState((state) => { return { ...state, timeLeft: state.timeLeft - 1 }; }); }; テストを通過しました。 次に、リファクタリングを行います。 以下の警告が出ているので、これを潰します。 Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. アンマウント時にタイマーのカウントのsetIntervalをクリアするようにします。 App.tsx import React, { useEffect, useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; /** タイマーモード */ type TimerMode = "work" | "break"; interface State { timeLeft: number; isTimerOn: boolean; timerMode: TimerMode; } /** タイマーのカウントのsetIntervalのID */ let timerCountInterval = 0; /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, timerMode: "work", }); useEffect(() => { return () => { clearInterval(timerCountInterval); }; }, []); const onButtonClick = () => { setState((state) => { timerCountInterval = window.setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: !state.isTimerOn }; }); }; const timerCount = () => { setState((state) => { return { ...state, timeLeft: state.timeLeft - 1 }; }); }; return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton" onClick={onButtonClick}> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode"> {state.timerMode === "work" ? "作業" : "休憩"} </div> </> ); }; export default App; 作業と休憩が切り替わるタイミングの表示のテスト 次に、作業から休憩に切り替わるタイミング(開始から25分+1秒後)と、休憩から作業に切り替わるタイミング(開始から25分+5分+1秒後)の表示のテストを書きます。 App.test.tsx(一部) describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => { test("「04:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 1) * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("04:59"); }); test("「休憩」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 1) * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("休憩"); }); }); describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => { test("「24:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:59"); }); test("「作業」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); テストの結果は以下のようになりました。作業と休憩の切り替えはまだ実装していないので、3つのテストで失敗しています。 App.tsxを編集し、作業と休憩の切り替え処理を実装します。 カウント時に残り時間が0以下のときに、toggleTimerMode関数が呼び出されます。 App.tsx import React, { useEffect, useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; /** タイマーモード */ type TimerMode = "work" | "break"; interface State { timeLeft: number; isTimerOn: boolean; timerMode: TimerMode; } /** タイマーのカウントのsetIntervalのID */ let timerCountInterval = 0; /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, timerMode: "work", }); useEffect(() => { return () => { clearInterval(timerCountInterval); }; }, []); const onButtonClick = () => { setState((state) => { timerCountInterval = window.setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: !state.isTimerOn }; }); }; const timerCount = () => { setState((state) => { if (state.timeLeft <= 0) { state = toggleTimerMode(state); } return { ...state, timeLeft: state.timeLeft - 1 }; }); }; const toggleTimerMode = (state: State): State => { const timeLeft = state.timerMode === "work" ? TIMER_LENGTH.break : TIMER_LENGTH.work; const timerMode = state.timerMode === "work" ? "break" : "work"; return { ...state, timeLeft: timeLeft, timerMode: timerMode, }; }; return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton" onClick={onButtonClick}> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode"> {state.timerMode === "work" ? "作業" : "休憩"} </div> </> ); }; export default App; テストを通過しました。 停止ボタンを押した後の表示のテスト 停止ボタンを押すパターンは、大きく分けると 作業中に停止する場合 休憩中に停止する場合 の2パターンあります。 todoを書き出すと以下のようになります。 App.test.tsx(一部) describe("停止ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => { test.todo("「25:00」と描画されていること"); test.todo("「作業」と描画されていること"); test.todo("停止してから1秒後に「25:00」と描画されていること"); }); describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => { test.todo("「25:00」と描画されていること"); test.todo("「作業」と描画されていること"); test.todo("停止してから1秒後に「25:00」と描画されていること"); }); }); まずは、「25:00」と描画されていることと、「作業」と描画されていることのテストを書きます。 App.test.tsx(一部) describe("停止ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => { test("「25:00」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「作業」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); test.todo("停止してから1秒後に「25:00」と描画されていること"); }); describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => { test("「25:00」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「作業」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); test.todo("停止してから1秒後に「25:00」と描画されていること"); }); }); テスト結果は以下のようになりました。 残り時間のリセットと、休憩から作業に切り替える処理がまだ実装されていないことが分かります。 App.tsxを編集し、停止ボタンをクリックした時の残り時間のリセットと、休憩から作業に切り替える処理を実装します。 onButtonClick関数を以下のように書き換えました。 App.tsx(一部) const onButtonClick = () => { setState((state) => { if (state.isTimerOn) { return { ...state, timeLeft: TIMER_LENGTH.work, timerMode: "work", isTimerOn: false, }; } timerCountInterval = window.setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: true }; }); }; テストを通過しました。 リファクタリングする箇所は特に見つからなかったので、次に進みます。 停止してから1秒後に「25:00」と描画されていることのテストを書きます。 App.test.tsx(一部) describe("停止ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => { ... test("停止してから1秒後に「25:00」と描画されていること", () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => { ... test("停止してから1秒後に「25:00」と描画されていること", () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); }); テスト結果は以下のようになりました。 App.tsxを編集し、停止ボタンをクリックしたときにタイマーのカウントが止まるようにします。 onButtonClick関数に、clearInterval()を入れます。 App.tsx(一部) const onButtonClick = () => { setState((state) => { clearInterval(timerCountInterval); if (state.isTimerOn) { return { ...state, timeLeft: TIMER_LENGTH.work, timerMode: "work", isTimerOn: false, }; } timerCountInterval = window.setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: true }; }); }; テストを通過しました。 リファクタリングする箇所は特にありません。 最終的なコード App.tsx import React, { useEffect, useState } from "react"; /** タイマーの長さ */ const TIMER_LENGTH = { work: 25 * 60, break: 5 * 60 } as const; type TIMER_LENGTH = typeof TIMER_LENGTH[keyof typeof TIMER_LENGTH]; /** タイマーモード */ type TimerMode = "work" | "break"; interface State { timeLeft: number; isTimerOn: boolean; timerMode: TimerMode; } /** タイマーのカウントのsetIntervalのID */ let timerCountInterval = 0; /** * 秒の数値をMM:SS形式の文字列に変換します。 * @param {number} second 秒 * @returns MM:SS形式の文字列 */ const secondToMMSS = (second: number) => { const MM = second >= 10 * 60 ? Math.floor(second / 60).toString() : second >= 1 * 60 ? "0" + Math.floor(second / 60).toString() : "00"; const SS = second % 60 >= 10 ? second % 60 : "0" + (second % 60); return MM + ":" + SS; }; const App = () => { const [state, setState] = useState<State>({ timeLeft: TIMER_LENGTH.work, isTimerOn: false, timerMode: "work", }); useEffect(() => { return () => { clearInterval(timerCountInterval); }; }, []); const onButtonClick = () => { setState((state) => { clearInterval(timerCountInterval); if (state.isTimerOn) { return { ...state, timeLeft: TIMER_LENGTH.work, timerMode: "work", isTimerOn: false, }; } timerCountInterval = window.setInterval(() => { timerCount(); }, 1000); return { ...state, isTimerOn: true }; }); }; const timerCount = () => { setState((state) => { if (state.timeLeft <= 0) { state = toggleTimerMode(state); } return { ...state, timeLeft: state.timeLeft - 1 }; }); }; const toggleTimerMode = (state: State): State => { const timeLeft = state.timerMode === "work" ? TIMER_LENGTH.break : TIMER_LENGTH.work; const timerMode = state.timerMode === "work" ? "break" : "work"; return { ...state, timeLeft: timeLeft, timerMode: timerMode, }; }; return ( <> <div data-testid="timeLeft">{secondToMMSS(state.timeLeft)}</div> <button data-testid="timerButton" onClick={onButtonClick}> {state.isTimerOn ? "停止" : "開始"} </button> <div data-testid="timerMode"> {state.timerMode === "work" ? "作業" : "休憩"} </div> </> ); }; export default App; App.test.tsx import React from "react"; import { act, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import App from "./App"; describe("初期表示", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「開始」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timerButton").textContent).toEqual("開始"); }); test("「作業」が描画されていること", () => { const { getByTestId } = render(<App />); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); describe("開始ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押した直後の表示のテスト", () => { test("「25:00」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「停止」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerButton").textContent).toEqual("停止"); }); test("「作業」が描画されていること", () => { const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); describe("開始ボタンを押してから999ミリ秒後の表示のテスト", () => { test("「25:00」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(999); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); describe("開始ボタンを押してから1000ミリ秒後の表示のテスト", () => { test("「24:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:59"); }); }); describe("開始ボタンを押してから2000ミリ秒後の表示のテスト", () => { test("「24:58」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:58"); }); }); describe("開始ボタンを押してから25分後の表示のテスト", () => { test("「00:00」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(25 * 60 * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("00:00"); }); test("「作業」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(25 * 60 * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); describe("開始ボタンを押してから25分+1秒後の表示のテスト", () => { test("「04:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 1) * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("04:59"); }); test("「休憩」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 1) * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("休憩"); }); }); describe("開始ボタンを押してから25分+5分+1秒後の表示のテスト", () => { test("「24:59」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000); }); expect(getByTestId("timeLeft").textContent).toEqual("24:59"); }); test("「作業」が描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 5 * 60 + 1) * 1000); }); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); }); }); describe("停止ボタンを押した後の表示のテスト", () => { describe("開始ボタンを押してから2秒後に停止ボタンを押した後の表示のテスト", () => { test("「25:00」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「作業」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); test("停止してから1秒後に「25:00」と描画されていること", () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(2 * 1000); }); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); describe("開始ボタンを押してから25分+2秒後に停止ボタンを押した後の表示のテスト", () => { test("「25:00」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); test("「作業」と描画されていること", async () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); expect(getByTestId("timerMode").textContent).toEqual("作業"); }); test("停止してから1秒後に「25:00」と描画されていること", () => { jest.useFakeTimers(); const { getByTestId } = render(<App />); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime((25 * 60 + 2) * 1000); }); userEvent.click(getByTestId("timerButton")); act(() => { jest.advanceTimersByTime(1000); }); expect(getByTestId("timeLeft").textContent).toEqual("25:00"); }); }); }); 最終的なテスト結果はこのようになりました。 途中経過と最終的なコードをGitHubにも上げています。 最後に 今回作ったテストコードは完璧ではありません。 例えば、タイマーの開始と停止が何度も行われた場合にタイマーが正常に作動するかは分かりませんし、タイマーのカウントの表示が常に正しく表示されているかも分かりません。 重要度が高いものをテストしています。 また、テスト駆動開発を実践するにあたって、以下のことが重要だと感じました。 要件定義を厳密にする 要件をうまくテストケースに落とし込む 実は今回のテスト駆動開発に挑戦する前に、同じポモドーロタイマーを、実装を先に書いてからテストコードを書く方法で作っています。 テスト駆動開発は、以前に似たようなテストコードを作った経験がないと本当に難しいと思いました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

axiosをクラス化してメソッドを再利用しやすくする

概要 現在udemyでReact.jsとNode.jsでECサイトを作成する講座を学んでいるのですが、その際axiosで使うメソッドを再利用するための方法が少し面倒で、こっちの方が良いんじゃないかと思ったのでここにメモを残しておきます。 udemy講座のやり方 // src/fucntions/auth.js import axios from "axios"; export const createOrUpdateUser = async (authtoken) => { return await axios.post( `${process.env.REACT_APP_API}/create-or-update-user`, {}, { headers: { authtoken, }, } ); }; export const currentAdmin = async (authtoken) => { return await axios.post( `${process.env.REACT_APP_API}/current-admin`, {}, { headers: { authtoken, }, } ); }; // src/fucntions/category.js export const updateCategory = async (slug, category, authtoken) => await axios.put(`${process.env.REACT_APP_API}/category/${slug}`, category, { headers: { authtoken, }, }); なんかごちゃごちゃして見づらいです。 メソッドを取り出して使う時もいちいちimportする際にメソッドを指定する必要があります。 import { createorUpdateUser, currentAdmin } from '../../functions/auth'; import { updateCategory } from '../../functions/auth'; const handleSubmit = (e) => { e.preventDefault(); createCategory({ name }, user.token) .then((res) => { ... } クラス化 再利用しやすくするためaxiosのメソッドをクラスに入れます。 // src/utils/API.js import axios from 'axios'; // 共通処理 const instance = axios.create({ baseURL: process.env.REACT_APP_API_URL, timeout: 30000, cors: true, }) // クラス化 class API { createOrUpdateUser(authToken) { return instance.request({ method: 'POST', headers: { authToken, }, url: '/create-or-update-user' }); } currentUser(authToken) { return instance.request({ method: 'POST', headers: { authToken, }, url: '/current-user' }); } updateCategory (slug, category, authToken) { return instance.request({ method: 'PUT', headers: { authToken }, url: `/category/${slug}`, data: { name: category, } }) } } export default new API(); これで大分見やすくなりました。こちらの方が何のためのメソッドかがぱっと見でわかるので良い気がします。 使う時はAPI.メソッド名とするだけで呼び出すことができます。 axiosは非同期処理ですので呼び出す時はasync/awaitを追加するのを忘れないでください。 import API from '../../utils/API'; const handleSubmit = async (e) => { e.preventDefault(); await API.createCategory(name, user.token) .then((res) => { ... } まとめ クラス化した方がコードがスッキリして再利用しすくなりました。 話が変わりますが.envファイルに入れた環境変数が呼ばれないのなんでだろうと思ってたのですがcreate-react-app を使うと REACT_APP_ を環境変数の前に入れないといけないみたいです。 参照
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[React] styled-component を利用する際の最初に知っておきたいことまとめ

はじめに これまでVuejsで開発をしてきたのですが、新しいプロジェクトでReactを利用することになりました。 キャッチアップのために、CSS in JS の代表的なライブラリの1つであるstyled-componentsを利用して簡単なWebサイトを作成しました。 実装を進める中で自分が調査して、なるほどと思ったポイントをまとめました。 [作成したサイト] [Github] CSS in JS について Reactのスタイリングの方法について CSS in JS について説明する前にまず基本として、Reactでは要素をどのようにスタイリングするかについて記載します。 Reactでは、要素のスタイリングに下記の2つの方法を用意しています。 通常のHTMLでスタイリングをするようにcssファイルを別に指定して、classNameプロパティに指定する Reactエレメントのstyleプロパティにcssに解釈できるオブジェクトを渡して、スタイリングを行う React公式では、パフォーマンスの観点から1の方法を推奨しています。 この公式の見解をそのまま当てはめるとcssファイルでスタイリングは定義したほうがいいとなります。 ただしCSSはグローバルスコープなので、比較的大きめのアプリケーションを実装する場合には名前の衝突やスタイルの影響範囲などをどのように担保するかの解決方法を検討する必要があります。 CSS in JS をなぜ利用するか CSS in JS は、cssの定義をjsファイルに記載して、上記の2のようにインラインスタイルで定義することでcssのスコープをそのコンポーネントに閉じようというコンセプトでスタイリングを行う手法です。 Facebookの@Vjeuxさんの「React: CSS in JS」 でこの手法が有名になったようで現在では様々なライブラリが実装されています。 CSS in JS を採用する理由は、主に下記です。 cssのスコープを対象のコンポーネントに閉じることでスタイル崩れを心配することなく開発したい JSXで記載したHTMLの構造とcssのスタイルを一緒に管理できる JSの既存のツールを利用して、静的解析やコードの入力補完がしやすい [CSS in JS の各ライブラリ] 2021年 CSS in JS のライブラリはかなり色々と出ています。 Emotion styled-components JSS glamorous Linaria CSS Modules などがざっと調べただけでも出てきます。 npm trend を確認すると、emotion が一番人気ですが、jss / styled-components もかなり近いレベルで採用されているようです。 最初はemotionを利用してみようと思ったのですが、css記法を利用する際には JSX Pragmaの対応が必要とのことでまずはReactに慣れることを優先して簡単に導入できるstyled-componentsを採用しました。 (v4以降ではstyled-componentsでもcss propに対応したため、css propを利用したいという場合でもstyled-componentsで対応できるようになりました。) styled-componentsでできること styled-componentsはReactコンポーネントをCSSでスタイルすることが主な目的なのですが、下記のように様々なスタイリングのパターンに対応しています。 コンポーネントに共通したテーマの設定(ThemeProviderを利用) 既存コンポーネントへのスタイルの適用(参考:Styling any component ) 擬似クラス / 擬似要素の適用(参考:Pseudoelements, pseudoselectors, and nesting ) React Nativeでの利用(参考: https://styled-components.com/docs/basics#react-native) styled-componentsの使い方をざっくり知りたいという入門者向けの情報 まずは、ざっくりstyled-componentsの使い方が知りたいという方は下記のリンクが参考になります。 公式サイト: https://styled-components.com/docs/basics#motivation styled-componentsの使い方の初歩を丁寧に解説してくれている https://www.webprofessional.jp/style-react-components-styled-components/ styled-componentsの基本的な使い方、Theme, Global https://akabeko.me/blog/2020/11/styled-components/ 比較的新しめのstyled-componentsの機能の紹介 個人的にこんなことまでできるんだと驚いた最後に比較的新しめの機能を3つ紹介させていただきます。 ポリモーフィズムによるHTMLタグの動的な切り替え css prop への対応 transient props 1. ポリモーフィズムによるHTMLタグの動的な切り替え これまではstyledの適用をする際にラップするHTML要素を決めなれけばならず、cssは同じだけどタグだけ変更したいとかpropsによって動的にHTMLの属性を変更したいというケースでは対応が難しい状態でした。 しかし、この機能を利用すれば、HTMLの属性をas propsによってrenderする際に決めることができます。 (乱用すると定義した場所でのタグの意味がなくなるので、注意は必要です。) データによって表示を切り替える必要のある複雑なリストやグリッドを実装するときには重宝すると思います。 // as propsを利用した例: import styled from "styled-components"; const Component = styled.div` color: red; `; render( <Component as="button" > Hello World! </Component> ) 上記のJSXは下記のようにbuttonタグに解釈されます。 // render されたHTML <button class="sc-bqyKva ehfErK">Hello World!</button> 2. css prop への対応 以前はstyled-composentはcss propに対応できておらず、必ずスタイリング対象のHTMLタグが必要となっていたため無駄なDOMが構築されてしまうケースがありました。 CSS in JS系の有名なライブリとしてはemotionはcss propに対応できていたため、css prop が利用したい場合はstyled-composentを採用せず、emotionを利用することが多かったようです。 css propに対応できたことで今後はstyled-composentが採用されることも増えるかと思います。 補足ですが、Typescriptで利用する場合には、下記の記載を追加しないと型エラーが出るようなのでご注意ください。 import {} from 'styled-components/cssprop' 3. transient props styled-composentで既存コンポーネントのスタイリングをpropsを利用して行う場合に、propsが意図せず伝播してしまうことがありました。 少しわかりにくいので、例で説明します。 下記のように普通にスタイリングを行うと、Containerコンポーネントにのみprops.colorを適用させたい場合でもTextにまでprops.colorが伝播されてしまいます。 // 普通にスタイリングした場合 import styled from "styled-components"; const Text = props => <p {...props} />; const Container = styled(Text)` color: ${p => p.color}; `; // colorがTextにまで伝播してしまう <Container color="blue">Hello world!</Container> この例のように自作コンポーネントの場合は、注意して実装すれば問題ないのですが、ライブラリなどで実装されたコンポーネント(例えば react-routerのなど) は自分でpropsを設定できないので伝播させたくない場合に困ってしまいます。 このような場合には、対象のpropsに$を利用することでpropsを伝播させなくするのがtransient propsの機能です。 // 普通にスタイリングした場合 import styled from "styled-components"; const Text = props => <p {...props} />; const Container = styled(Text)` color: ${p => p.$color}; `; // colorはContainer <Container $color="blue">Hello world!</Container> transient props の詳細な仕様については下記の記事が詳しいので、気になる方はご参照ください。 参考リンク React公式: CSS とスタイルの使用 CSS Modules 所感 食べログ フロントエンドエンジニアブログ:SassからCSS Modules、そしてstyled-componentsに乗り換えた話 クックパッド開発者ブログ:レシピサービスのフロントエンドに CSS in JS を採用した話 Emotion > css props > JSX Pragma styled-component > API Reference > "as" polymorphic prop styled-component > API Reference > Transient props Evan Jacobs: Introducing “transient” props Github:[styled-component] Support css prop
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HTML + CSS + JavaScript + React でカウンター機能を実装する

はじめに この記事ではReactのHooksである「useState」を使用して、カウンターアプリを作成する方法を解説します。必要な部分のみ参考にしていただけたらと思います。 ソースコード 「App.jsx」「index.js」「index.html」「styles.css」のコードはこちらになります。 App.jsx import { useState } from "react"; import "./styles.css"; export default function App() { // カウント保持(初期値:0) const [count, setCount] = useState(0); // カウントアップ処理 const countUp = () => { setCount(count + 1); }; return ( <div className="App"> <h1>カウンタ</h1> <p> {count} </p> <button onClick = {countUp}> + </button> </div> ); } index.js import { StrictMode } from "react"; import ReactDOM from "react-dom"; import App from "./App"; const rootElement = document.getElementById("root"); ReactDOM.render( <StrictMode> <App /> </StrictMode>, rootElement ); index.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <title>React App</title> </head> <body> <noscript> You need to enable JavaScript to run this app. </noscript> <div id="root"></div> </body> </html> styles.css .App { text-align: center; } 実行結果 実行結果はこのようになります。 プラスボタンをクリックすると、下記画像のように数字がカウントアップされます。 以上になります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

個人開発で会計ソフトを開発しました

各種リンク front-end https://github.com/nobu0605/accounting-application back-end https://github.com/nobu0605/accounting-application-api application https://accounting-application-front.herokuapp.com 背景 自分はエンジニアにキャリアチェンジする前に、経理などのバックオフィスの仕事を3年半ほどしていました。経理の仕事には今も興味があるので、今回個人で会計ソフトを開発してみました。 機能(一部) ・言語切り替え 日本語と英語を選ぶ事ができます。 日本語 英語 ・振替伝票 仕訳を入力する事ができます。 ・財務レポート ・仕訳一覧 使用した技術 ・フロント React version 17.0.1 Typescript version 4.0.5 ・バックエンド Laravel Framework 6.20.4 PHP 7.3.24 MySQL 5.7 ・CI/CD GitHub Actions ・サーバー Heroku ・ER図 https://drive.google.com/file/d/14r1bxllKWg7Ih6xf9yeYxHyRrDL5FkrU/view?usp=sharing
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactとaxiosで.envを用いて環境変数を利用する方法

ReactでAPI通信を行う際に、エンドポイントやAPIキーを環境変数に格納する必要がありますが、その方法で30分くらいググったのでメモ。。。 初心者の方や、しばらく触ってなくて忘れてしまった方の参考になれば幸いです。 ちなみにReactの環境構築はcreate-react-app、API通信はaxiosで行う想定としています。 ぱっと試す環境を作りたい方は以下コピペでどうぞ。 npx create-react-app sample-app cd sample-app yarn add axios yarn start Reactで環境変数を使用する際の重要ポイント Reactで環境変数を使用する際は、以下の3つのポイントに注意する必要があります。 envファイルはルート直下に配置する 環境変数を使用する際は「process.env.REACT_APP_」を接頭辞に使う 変更を反映する際はサーバーを再起動 envファイルはルート直下に配置する envファイルは、プロジェクトのルート直下に配置する必要があります。 create-react-appでReactアプリを作成すると、初期状態でルート直下に以下のようなディレクトリ、ファイルが作成されているはずです。 public/ ←この下にindex.htmlなど src/ ←この下にindex.jsなど .gitignore README.md package.json yarn.lock ※すみません、tree出力するのめんどくさかったのでこのような表記になりました... 重要な事は、 'envファイルは.gitignoreやREADMEなどと同じレベルの階層に設置する必要がある' という事です。 src/の下などに配置しないようにしましょう。 環境変数を使用する際は「process.env.REACT_APP_」を接頭辞に使う 環境変数を実際に呼び出す際は、環境変数の頭に「process.env.REACT_APP_」をつける必要があります。 以下のような感じです。 // これは.envファイル REACT_APP_API_URL=https://hogehogehogehoge REACT_APP_API_KEY=******-********-******** hoge.js // これは環境変数を用いたいコンポーネントのjsファイル import axios from "axios"; const options = { method: 'GET', url: process.env.REACT_APP_API_URL, headers: { 'api-key': process.env.REACT_APP_API_KEY, 'api-host': process.env.REACT_APP_API_HOST } }; 環境変数は '' や "" で囲む必要はないです。 おしまい おそらくこれでいけるんじゃないかと。 気になったところとかあればコメントいただけると幸いです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで.envを用いて環境変数を利用する方法

Reactを使用してAPI通信を行う際に、エンドポイントやAPIキーを環境変数に格納する必要がありますが、その方法で30分くらいググる必要があったのでメモ。。。 初心者の方や、しばらく触ってなくて忘れてしまった方の参考になれば幸いです。 ちなみにReactの環境構築はcreate-react-app、API通信はaxiosで行う想定としています。 ぱっと試す環境を作りたい方は以下コピペでどうぞ。 npx create-react-app sample-app yarn add axios yarn start Reactで環境変数を使用する際の重要ポイント Reactで環境変数を使用する際は、以下の3つのポイントに注意する必要があります。 envファイルはルート直下に配置する 環境変数を使用する際は「process.env.REACT_APP_」を接頭辞に使う 変更を反映する際はサーバーを再起動 envファイルはルート直下に配置する envファイルは、プロジェクトのルート直下に配置する必要があります。 create-react-appでReactアプリを作成すると、初期状態でルート直下に以下のようなディレクトリ、ファイルが作成されているはずです。 public/ ←この下にindex.htmlなど src/ ←この下にindex.jsなど .gitignore README.md package.json yarn.lock ※すみません、tree出力するのめんどくさかったのでこのような表記になりました... 重要な事は、envファイルは.gitignoreやREADMEなどと同じレベルの階層に設置する必要があるという事です。 src/の下などに配置しないようにしましょう。 環境変数を使用する際は「process.env.REACT_APP_」を接頭辞に使う 環境変数を実際に呼び出す際は、環境変数の頭に「process.env.REACT_APP_」をつける必要があります。 以下のような感じです。 // これは.envファイル REACT_APP_API_URL=https://hogehogehogehoge REACT_APP_API_KEY=******-********-******** hoge.js // これは環境変数を用いたいコンポーネントのjsファイル import axios from "axios"; const options = { method: 'GET', url: process.env.REACT_APP_API_URL, headers: { 'api-key': process.env.REACT_APP_API_KEY, 'api-host': process.env.REACT_APP_API_HOST } }; 環境変数は '' や "" で囲む必要はないです。 おしまい おそらくこれでいけるんじゃないかと。 気になったところとかあればコメントいただけると幸いです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails API + Reactで作る俺流アニメデータベース

概要 自分はアニメを見るのが趣味です。昨今のコロナ事情によってリモートワークが基本となった事もあり、以前よりもアニメに没頭する機会が増えました。 毎日のように「今期のアニメで面白そうな作品は無いかなぁ」なんて探しているわけですが、どうもアニメの情報って効率的に取得しづらい気がしています。 もちろん、世の中にはたくさんのアニメ情報サイトが存在しているものの、自分にとっては必要無い情報がたくさん羅列されていたりしてしっくり来ない事もしばしば。 たとえば、私が視聴するアニメを選ぶ基準としては、 どんなスタッフが携わっているか どんな声優さんが出演されているか キャラデザインは自分好みか 世間的な注目度は高そうか といったものが主な判断材料となっています。 要するに、製作陣やキャスト陣、キービジュアルやSNSのフォロワー数などが一目でわかれば情報としてはそれなりに十分というわけですね。 そこで今回は、↑の要件を満たすアプリを自分で作ってみる事にしました。 完成イメージ 年代・季節ごとに作品を絞り込み 作品のタイトルで個別に検索 作品のイメージ画像 製作陣やキャスト陣の情報一覧 公式サイトやTwitterアカウントへのリンク 必要最低限な機能・情報がコンパクトにまとまっていると思います。 主な使用技術・サービス バックエンド Ruby Rails API MySQL フロントエンド React TypeScript 外部サービス Annict 見たアニメを記録したり、見た感想を友達にシェアすることができるWebサービス。APIを公開しており、各作品の情報を取得する事ができる。 しょぼいカレンダー アニメの番組表などが確認できるWebサービス。こちらもAPIを公開しており、各作品の情報を取得する事ができる。 ※ 再現性を考慮してバックエンドのみDockerで環境構築を行います。 Annict、しょぼいカレンダーともにアニメ好きであれば一度は利用した事があるのではないでしょうか。簡単な情報からマニアックな情報まで網羅的に掲載してくれている素晴らしいWebサービスです。 それぞれAPIを公開しているため、素直にそれらを使えば良いんじゃねって思われるかもしれませんが、どちらも個人的には痒いところにあと一歩届かない感があったので、色々こねくり回して扱いやすい形に整形するためバックエンドを準備しました。 実装 前置きはほどほどに実装を開始しましょう。 バックエンド 先にバックエンド側から。 環境構築 何はともあれ環境構築を行います。 各種ディレクトリ・ファイルを作成 $ mkdir aninfo-backend && cd aninfo-backend $ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock ./Dockerfile FROM ruby:2.6.3 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs mariadb-client ENV APP_PATH /myapp RUN mkdir $APP_PATH WORKDIR $APP_PATH COPY Gemfile $APP_PATH/Gemfile COPY Gemfile.lock $APP_PATH/Gemfile.lock RUN bundle install COPY . $APP_PATH COPY entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"] ./docker-compose.yml version: "3" services: db: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: password volumes: - mysql-data:/var/lib/mysql - /tmp/dockerdir:/etc/mysql/conf.d/ ports: - 4306:3306 api: build: context: . dockerfile: Dockerfile command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" volumes: - .:/myapp - ./vendor/bundle:/myapp/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development ports: - "3001:3000" depends_on: - db volumes: mysql-data: ./entrypoint.sh #!/bin/bash set -e # Remove a potentially pre-existing server.pid for Rails. rm -f /myapp/tmp/pids/server.pid # Then exec the container's main process (what's set as CMD in the Dockerfile). exec "$@" ./Gemfile # frozen_string_literal: true source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "rails", "~> 6" ./Gemfile.lock # 空欄でOK rails new APIモードで作成します。 $ docker-compose run api rails new . --force --no-deps -d mysql --api database.ymlを編集 デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。 ./config/database.yml default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password # デフォルトだと空欄になっているはずなので変更 host: db # デフォルトだとlocalhostになっているはずなので変更 development: <<: *default database: myapp_development test: <<: *default database: myapp_test production: <<: *default database: <%= ENV["DATABASE_NAME"] %> username: <%= ENV["DATABASE_USERNAME"] %> password: <%= ENV["DATABASE_PASSWORD"] %> コンテナを起動 & データベースを作成 $ docker-compose build $ docker-compose up -d $ docker-compose run api bundle exec rails db:create localhost:3001 にアクセス localhost:3001 にアクセスして初期状態の画面が表示されればOKです。 gemをインストール 後々の処理で必要になるgemをインストールしておきます。 ./Gemfile gem 'faraday' gem 'syobocal' gem 'dotenv-rails' fadaday HTTPクライアント用のgem syobocal しょぼいカレンダーから情報を取得しやすくしてくれるgem dotenv-rails 環境変数を管理するためのgem Gemfileを更新したので再度ビルド。 $ docker-compose build 各種モデルを作成 $ docker-compose run api rails g model Work title:string year:integer season:integer image:string twitter_username:string official_site_url:string media_text:string season_name_text:string syobocal_tid:integer $ docker-compose run api rails g model WorkDetail work_id:integer staffs:text casts:text syobocal_tid:integer $ docker-compose run api rails db:migrate Work(作品) ※ Annictから取得する情報 title 作品タイトル year 放送年 season 季節 image 作品イメージ twitter_username Twitterアカウント名 official_site_url 公式サイトURL media_text どのメディアで放送か(TV、映画、OVAなど) syobocal_tid しょぼいカレンダーのTID WorkDetail(作品の詳細)※ しょぼいカレンダーから取得する情報 work_id Workモデルとの関連付け用 staffs 製作陣 casts キャスト陣 syobocal_tid しょぼいカレンダーのTID ./app/models/work.rb class Work < ApplicationRecord enum season: { spring: 1, summer: 2, autumn: 3, winter: 4 } has_one :work_detail # Annictから情報を取得 def import_from_annict base_url = "https://api.annict.com/v1" access_token = ENV["ANNICT_ACCESS_TOKEN"] start_year = 1970 # どの年からデータを取得したいかを指定 end_year = Date.today.year seasons = ["spring", "summer", "autumn", "winter"] (start_year..end_year).each do |year| seasons.each.with_index(1) do |season, index| # 初回リクエストはデータの総数を調べるために実行 data = JSON.parse(Faraday.get("#{base_url}/works?fields=id&per_page=50&filter_season=#{year}-#{season}&sort_watchers_count=desc&access_token=#{access_token}").body) data_count = data["total_count"] # データの数 page_count = (data_count / 50.to_f).ceil # ページの数 current_page = 1 # 現在のページ <= ページの数になるまで繰り返し処理を実行 while current_page <= page_count do data = JSON.parse(Faraday.get("#{base_url}/works?fields=title,images,twitter_username,official_site_url,media_text,syobocal_tid,season_name_text&page=#{current_page}&per_page=50&filter_season=#{year}-#{season}&sort_watchers_count=desc&access_token=#{access_token}").body) works = data["works"] works.each do |work| # すでにレコードが存在する場合は更新、無ければ新規作成 Work.find_or_initialize_by(title: work["title"]).update( year: year, season: index, image: work["images"]["recommended_url"], twitter_username: work["twitter_username"], official_site_url: work["official_site_url"], media_text: work["media_text"], syobocal_tid: work["syobocal_tid"], season_name_text: work["season_name_text"] ) end current_page += 1 end end end end end ./app/models/work_detail.rb class WorkDetail < ApplicationRecord serialize :staffs, Array serialize :casts, Array belongs_to :work # しょぼいカレンダーから情報を取得 def import_from_syobocal titles = Syobocal::DB::TitleLookup.get({ "TID" => "*" }) titles.each do |title| comment = title[:comment] parser = Syobocal::Comment::Parser.new(comment) staffs = [] # 製作陣 casts = [] # キャスト陣 parser.staffs.each do |staff| staffs << { "role": staff.instance_variable_get("@role"), "name": staff.instance_variable_get("@people")[0].instance_variable_get("@name") } end parser.casts.each do |cast| casts << { "character": cast.instance_variable_get("@character"), "name": cast.instance_variable_get("@people")[0].instance_variable_get("@name") } end tid = title[:tid] work = Work.find_by(syobocal_tid: tid) # すでにレコードが存在する場合は更新、無ければ新規作成 WorkDetail.find_or_initialize_by(syobocal_tid: tid).update( work_id: work ? work.id : nil, staffs: staffs, casts: casts ) end end end 各情報をデータベースにインポート Annict、しょぼいカレンダーから各情報をデータベースにインポートします。ただし、Annictに関してはAPIを利用するためのアクセストークンが必要になるので、公式ドキュメントの手順に従い事前に取得しておいてください。 Annict API 公式ドキュメント アクセストークンが取得できたら、ルートディレクトリに「.env」ファイルを作成してそこに環境変数としてセットします。 $ touch .env .env ANNICT_ACCESS_TOKEN=*********************** その後、Railsコンソールを立ち上げてそれぞれインポートを開始してください。 $ docker-compose run api rails c irb(main):001:0> Work.new.import_from_annict Work Load (0.6ms) SELECT `works`.* FROM `works` WHERE `works`.`title` = 'あしたのジョー' LIMIT 1 TRANSACTION (0.4ms) BEGIN Work Create (0.6ms) INSERT INTO `works` (`title`, `year`, `season`, `image`, `twitter_username`, `official_site_url`, `media_text`, `season_name_text`, `created_at`, `updated_at`) VALUES ('あしたのジョー', 1970, 1, '', '', '', 'TV', '1970年春', '2021-07-23 16:04:36.882115', '2021-07-23 16:04:36.882115') TRANSACTION (2.2ms) COMMIT ... irb(main):002:0> WorkDetail.new.import_from_syobocal Work Load (4.5ms) SELECT `works`.* FROM `works` WHERE `works`.`syobocal_tid` = 1 LIMIT 1 WorkDetail Load (0.5ms) SELECT `work_details`.* FROM `work_details` WHERE `work_details`.`syobocal_tid` = 1 LIMIT 1 TRANSACTION (0.3ms) BEGIN Work Load (2.0ms) SELECT `works`.* FROM `works` WHERE `works`.`id` = 2194 LIMIT 1 WorkDetail Create (0.8ms) INSERT INTO `work_details` (`work_id`, `staffs`, `casts`, `syobocal_tid`, `created_at`, `updated_at`) VALUES (2194, '---\n- :role: 監督\n :name: 下田正美\n- :role: 原作・脚本\n :name: 山田典枝\n- :role: 掲載\n :name: 月刊コミックドラゴン\n- :role: キャラクター原案\n :name: よしづきくみち\n- :role: キャラクターデザイン\n :name: 千葉道徳\n- :role: 総作画監督\n :name: 川崎恵子\n- :role: コンセプト・ワークス\n :name: 横田耕三\n- :role: 美術監督\n :name: 西川淳一郎\n- :role: 色彩設定\n :name: 石田美由紀\n- :role: 撮影監督\n :name: 秋元央\n- :role: 編集\n :name: 西山茂\n- :role: 音響監督\n :name: 田中英行\n- :role: 音楽\n :name: 羽毛田丈史\n- :role: 音楽プロデューサー\n :name: 廣井紀彦\n- :role: 音楽ディレクター\n :name: 和田亨\n- :role: 音楽協力\n :name: テレビ朝日ミュージック\n- :role: 録音調整\n :name: 小原吉男\n- :role: 音響効果\n :name: 今野康之\n- :role: 選曲\n :name: 神保直史\n- :role: 録音助手\n :name: 国分政嗣\n- :role: 録音スタジオ\n :name: タバック\n- :role: 音響制作\n :name: オーディオ・タナカ\n- :role: キャスティング協力\n :name: 好永伸恵\n- :role: ポストプロダクション\n :name: 東京現像所\n- :role: 広報\n :name: 小出わかな\n- :role: 宣伝プロデュース\n :name: 小林 剛\n- :role: アシスタントプロデューサー\n :name: 佐々木美和\n- :role: プロデューサー\n :name: 清水俊\n- :role: アニメーションプロデューサー\n :name: 新崎力也\n- :role: 企画\n :name: 角川大映\n- :role: アニメーション制作\n :name: ヴューワークス\n- :role: 制作\n :name: 魔法局\n', '---\n- :character: 菊池ユメ\n :name: 宮﨑あおい\n- :character: 小山田雅美\n :name: 諏訪部順一\n- :character: ケラ(加藤剛)\n :name: 飯田浩志\n- :character: アンジェラ\n :name: 渡辺明乃\n- :character: 遠藤耕三\n :name: 中博史\n- :character: 古崎力哉\n :name: 清川元夢\n- :character: 森川瑠奈\n :name: 石毛佐和\n- :character: ギンプン\n :name: 辻谷耕史\n- :character: ミリンダ\n :name: 平松晶子\n', 1, '2021-07-23 16:12:43.543292', '2021-07-23 16:12:43.543292') TRANSACTION (2.2ms) COMMIT ... 最終的にこんな感じでそれぞれの情報が格納されていれば成功です。(※ 古い作品などは空欄になってしまう箇所多し) APIを作成 データベースに格納した情報をJSON形式で返すAPIを作成します。 コントローラー $ docker-compose run api rails g controller api/v1/works ./app/controllers/api/v1/works_controller.rb class Api::V1::WorksController < ApplicationController def index return if !params[:year] && !params[:season] && !params[:title] works = [] # paramsによって絞り込みの条件を変更 queried_works = params[:year] && params[:season] ? Work.where(year: params[:year], season: params[:season]) : Work.where("title like ?", "%#{params[:title]}%") queried_works.each do |work| works << { id: work.id, # ID title: work.title, # 作品のタイトル year: work.year, # 年 season: work.season, # 季節 image: work.image, # 画像 staffs: work.work_detail ? work.work_detail.staffs : [], # 製作陣 casts: work.work_detail ? work.work_detail.casts : [], # キャスト陣 twitter_username: work.twitter_username, # Twitterのユーザー名 official_site_url: work.official_site_url, # 公式サイトのURL media_text: work.media_text, # ex. TV、映画、OVA、Web season_name_text: work.season_name_text # ex. 2021年春 } end render json: { status: 200, works: works } end end クエリパラメータに「year」と「season」が含まれていた場合は放送タイミングで作品を絞り込み、「title」が含まれていた場合は合致するタイトルの作品を絞り込むようにしました。 なお、Annictから取得した情報(Work)には製作陣(staffs)やキャスト陣(staffs)が含まれていなかったため、しょぼいカレンダーから取得した情報(WorkDetail)をプラスして情報の網羅性を高めています。 ルーティング ./backend/config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :works, only: %i[index] end end end 動作確認 $ curl -X GET http://localhost:3001/api/v1/works?year=2021&season=3 $ curl -X GET http://localhost:3001/api/v1/works?title=小林さんちのメイドラゴンS curlコマンドを叩くなり、直接URLを打ち込んでアクセスするなりしてJSONが返ってくればOK。 CORS設定 今回の構成ではバックエンドとフロントエンドを完全に分けているため、RailsとReactがそれぞれ別のドメインで立ち上がっています。(localhost:3001とlocalhost:3000) この場合、デフォルトの状態だとセキュリティの問題でReactからRailsのAPIを使用できない点に注意が必要です。 これを解決するためには「CORS(クロス・オリジン・リソース・シェアリング)」の設定を行わなければなりません。 参照記事: オリジン間リソース共有 (CORS) rack-corsをインストール RailsにはCORSの設定を簡単に行えるgemがあるのでそちらをインストールしましょう。 ./Gemfile gem 'rack-cors' APIモードで作成している場合、すでにGemfile内に記載されているのでコメントアウトを外すだけでOKです。 $ docker-compose build Gemfileを更新したので再度ビルド。 cors.rbを編集 「config/initializers/」に設定ファイルが存在するはずなので、外部からアクセス可能なように編集しておきます。 ./backend/config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "*" resource "*", headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end 設定の変更を反映させるためにコンテナを再起動。 $ docker-compose down $ docker-compose up -d これでバックエンド側の準備は完了です。 フロントエンド 次にフロントエンド側の実装に入ります。 環境構築 何はともあれ環境構築を行います。 各種ディレクトリ・ファイルを作成 おなじみの「create-react-app」でアプリの雛形を作ります。 $ mkdir aninfo-frontend && cd aninfo-frontend $ yarn create react-app . --template typescript tsconfig.jsonを修正 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス いちいち「../../」みたいな記述をしなくて済むというわけですね。 不要なファイルを整理 この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 「./src/index.tsx」と「./src/App.tsx」を次のように変更します。 ./src/index.tsx import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) ./src/App.tsx import React from "react" const App: React.FC = () => { return ( <h1>Hello World!</h1> ) } export default App 一旦、動作確認してみましょう。 $ yarn start localhost:3000 にアクセスして「Hello World!」と返ってくればOK。 型定義 プロジェクト全体で使い回す事になるであろう型(今回であればWork)を「./src/interfaces/index.ts」の中に記述しておきます。 $ mkdir src/interfaces $ touch src/interfaces/index.ts ./src/interfaces/index.ts export interface Work { id: number title: string year: number season: number image?: string staffs?: Array<{ role: string name: string }> casts?: Array<{ character: string name: string }> twitterUsername?: string officialSiteUrl?: string mediaText: string seasonNameText: string } APIを呼び出すための関数を作成 Railsで作成したAPIを呼び出すための関数を作成します。 $ mkdir src/lib $ mkdir src/lib/api $ touch src/lib/api/client.ts $ touch src/lib/api/works.ts $ touch .env.local $ yarn add axios axios-case-converter $ yarn add -D @types/axios axios HTTPクライアント用ライブラリ @types/axios 型定義用ライブラリ axios-case-converter axios経由で受け取るレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ ./src/lib/api/client.ts import applyCaseMiddleware from "axios-case-converter" import axios from "axios" /* applyCaseMiddleware axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換 または送信するリクエストの値をキャメルケース→スネークケースに変換 */ const railsApiBaseUrl = process.env.REACT_APP_RAILS_API_BASE_URL const client = applyCaseMiddleware(axios.create({ baseURL: `${railsApiBaseUrl}/api/v1` })) export default client 慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本なので、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。 .env.local REACT_APP_RAILS_API_BASE_URL=http://localhost:3001 ※ Reactで環境変数を使用する場合、環境変数名の先頭にREACT_APP_を付ける必要があるので注意。 動作確認 ./src/App.tsx import React, { useEffect, useState } from "react" import { getWorks } from "lib/api/works" import { Work } from "interfaces/index" const App: React.FC = () => { const [works, setWorks] = useState<Work[]>() const handleGetWorks = async (year?: number, season?: number, title?: string): Promise<void> => { const res = await getWorks(year, season, title) if (res.status === 200) { setWorks(res.data.works) } } useEffect(() => { handleGetWorks(2021, 3) // 20201年夏季のアニメ情報を取得 }, []) return ( <React.Fragment> { works?.map((work: Work) => ( <p>{work.title}</p> )) } </React.Fragment> ) } export default App localhost:3000 にアクセスして作品タイトルがズラーっと表示されていればOK。ちゃんと通信ができています。 各種ライブラリをインストール 後に必要となるライブラリをインストールしておきます。 $ yarn add @material-ui/core @material-ui/icons @material-ui/lab react-select $ yarn add -D @types/react-select material-ui UIを整える用のライブラリ react-select セレクトボックスが簡単に作れるライブラリ @types/react-select 型定義用ライブラリ 各種ビューを作成 各種ビューを作成します。 $ mkdir src/components $ mkdir src/components/layouts $ mkdir src/components/utils $ mkdir src/components/work $ touch src/components/layouts/Header.tsx $ touch src/components/utils/theme.ts $ touch src/components/work/SelectBox.tsx $ touch src/components/work/WorkDetail.tsx $ touch src/components/work/Works.tsx ./src/components/layouts/Header.tsx import React, { useState } from "react" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import IconButton from "@material-ui/core/IconButton" import Typography from "@material-ui/core/Typography" import InputBase from "@material-ui/core/InputBase" import { alpha, makeStyles } from "@material-ui/core/styles" import MenuIcon from "@material-ui/icons/Menu" import SearchIcon from "@material-ui/icons/Search" const useStyles = makeStyles((theme) => ({ root: { flexGrow: 1 }, menuButton: { marginRight: theme.spacing(2) }, title: { flexGrow: 1, display: "none", [theme.breakpoints.up("sm")]: { display: "block" } }, search: { position: "relative", borderRadius: theme.shape.borderRadius, backgroundColor: alpha(theme.palette.common.white, 0.15), "&:hover": { backgroundColor: alpha(theme.palette.common.white, 0.25), }, marginLeft: 0, width: "100%", [theme.breakpoints.up("sm")]: { marginLeft: theme.spacing(1), width: "auto", } }, searchIcon: { padding: theme.spacing(0, 2), height: "100%", position: "absolute", pointerEvents: "none", display: "flex", alignItems: "center", justifyContent: "center" }, inputRoot: { color: "inherit" }, inputInput: { padding: theme.spacing(1, 1, 1, 0), paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, transition: theme.transitions.create("width"), width: "100%", [theme.breakpoints.up("sm")]: { width: "12ch", "&:focus": { width: "20ch", } } } })) interface HeaderProps { handleGetWorks: Function setLoading: Function } const Header: React.FC<HeaderProps> = ({ handleGetWorks, setLoading }) => { const classes = useStyles() const [title, setTitle] = useState<string>("") return ( <div className={classes.root}> <AppBar position="static"> <Toolbar> <IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="open drawer" > <MenuIcon /> </IconButton> <Typography className={classes.title} variant="h6" noWrap> AnInfo </Typography> <div className={classes.search}> <div className={classes.searchIcon}> <SearchIcon /> </div> <InputBase placeholder="作品名で検索" classes={{ root: classes.inputRoot, input: classes.inputInput, }} inputProps={{ "aria-label": "search" }} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setTitle(e.target.value) console.log(title) }} onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") { setLoading(true) handleGetWorks(null, null, title) } }} /> </div> </Toolbar> </AppBar> </div> ) } export default Header ./src/components/utils/theme.ts import { createTheme } from "@material-ui/core/styles" import blue from "@material-ui/core/colors/blue" import green from '@material-ui/core/colors/green' const theme = createTheme({ palette: { primary: { main: blue[500] }, secondary: { main: green[500] } }, typography: { h1: { fontSize: "3rem", fontWeight: 500 }, h2: { fontSize: "2rem", fontWeight: 500 }, h3: { fontSize: "1.25rem", fontWeight: 500 }, h4: { fontSize: "1rem", fontWeight: 500 } } }) export default theme ./src/components/work/SelectBox.tsx import React, { useState } from "react" import Select, { OptionTypeBase } from "react-select" import { makeStyles } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import IconButton from "@material-ui/core/IconButton" import SearchIcon from "@material-ui/icons/Search" import FormControl from "@material-ui/core/FormControl" const useStyles = makeStyles(() => ({ gridContainer: { marginBottom: "2rem" }, iconButton: { padding: 10 }, formControl: { margin: "3px", minWidth: 130 } })) interface SelectBoxProps { years: Array<{ value: number label: string }> seasons: Array<{ value: number label: string }> handleGetWorks: Function setLoading: Function } const SelectBox: React.FC<SelectBoxProps> = ({ years, seasons, handleGetWorks, setLoading }) => { const classes = useStyles() const [year, setYear] = useState<number>() const [season, setSeason] = useState<number>() return ( <Grid className={classes.gridContainer} container justifyContent="center"> <FormControl className={classes.formControl}> <Select instanceId="year-select" placeholder="年" options={years} onChange={(e) => { setYear(e?.value) }} /> </FormControl> <FormControl className={classes.formControl}> <Select instanceId="season-select" placeholder="シーズン" options={seasons} onChange={(e) => { setSeason(e?.value) }} /> </FormControl> <IconButton type="submit" className={classes.iconButton} size="medium" color="default" disabled={!year || !season} onClick={() => { setLoading(true) handleGetWorks(year, season) }} > <SearchIcon /> </IconButton> </Grid> ) } export default SelectBox ./src/components/work/WorkDetail.tsx import { createStyles, Theme, withStyles, WithStyles } from "@material-ui/core/styles" import Button from "@material-ui/core/Button" import Dialog from "@material-ui/core/Dialog" import MuiDialogTitle from "@material-ui/core/DialogTitle" import MuiDialogContent from "@material-ui/core/DialogContent" import MuiDialogActions from "@material-ui/core/DialogActions" import Typography from "@material-ui/core/Typography" import IconButton from "@material-ui/core/IconButton" import CloseIcon from "@material-ui/icons/Close" import { Work } from "interfaces/index" import React from "react" const styles = (theme: Theme) => createStyles({ root: { margin: 0, padding: theme.spacing(2), }, closeButton: { position: "absolute", right: theme.spacing(1), top: theme.spacing(1), color: theme.palette.grey[500] } }) export interface DialogTitleProps extends WithStyles<typeof styles> { id: string children: React.ReactNode onClose: () => void } const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { const { children, classes, onClose, ...other } = props return ( <MuiDialogTitle disableTypography className={classes.root} {...other}> <Typography variant="h6">{children}</Typography> { onClose ? ( <IconButton aria-label="close" className={classes.closeButton} onClick={onClose}> <CloseIcon /> </IconButton> ) : null } </MuiDialogTitle> ) }) const DialogContent = withStyles((theme) => ({ root: { padding: theme.spacing(2), } }))(MuiDialogContent) const DialogActions = withStyles((theme) => ({ root: { margin: 0, padding: theme.spacing(1), } }))(MuiDialogActions) interface WorkDetailsProps { work: Work open: boolean handleClose: () => void } const WorkDetail: React.FC<WorkDetailsProps> = ({ work, open, handleClose }) => { return ( work.staffs != undefined && work.casts != undefined ? ( <Dialog onClose={handleClose} open={open} fullWidth> <DialogTitle id="customized-dialog-title" onClose={handleClose}> {work.title} </DialogTitle> <DialogContent dividers> <Typography variant="h4" gutterBottom> Staffs </Typography> { work.staffs.length > 1 ? work.staffs.map((staff, index: number) => ( <Typography key={index} variant="body2" gutterBottom> {staff.role}: {staff.name} </Typography> )) : <Typography variant="body2" gutterBottom>情報を取得できませんでした。</Typography> } <Typography variant="h4" gutterBottom style={{ marginTop: "1rem" }}> Casts </Typography> { work.casts.length > 1 ? work.casts.map((cast, index: number) => ( <Typography key={index} variant="body2" gutterBottom> {cast.character}: {cast.name} </Typography> )) : <Typography variant="body2" gutterBottom>情報を取得できませんでした。</Typography> } </DialogContent> <DialogActions> <Button autoFocus onClick={handleClose} color="primary"> 閉じる </Button> </DialogActions> </Dialog> ) : null ) } export default WorkDetail ./src/components/work/Works.tsx import React, { useState } from "react" import { makeStyles } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import Card from "@material-ui/core/Card" import CardMedia from "@material-ui/core/CardMedia" import CardContent from "@material-ui/core/CardContent" import CardActions from "@material-ui/core/CardActions" import Chip from "@material-ui/core/Chip" import CircularProgress from "@material-ui/core/CircularProgress" import Typography from "@material-ui/core/Typography" import WorkDetail from "components/work/WorkDetail" import { Work } from "interfaces/index" const useStyles = makeStyles(() => ({ circularProgress: { position: "absolute", top: "50%", left: "50%" }, card: { height: "100%", width: "100%", marginBottom: "0.5rem", transition: "all 0.3s", "&:hover": { boxShadow: "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)", transform: "translateY(-3px)", } }, cardMedia: { aspectRatio: "16/9", cursor: "pointer" }, cardActions: { marginTop: "0.5rem" } })) interface WorksProps { loading: boolean works: Work[] } const initialWorkState: Work = { id: 0, title: "", year: 0, season: 0, image: "string", staffs: [], casts: [], twitterUsername: "", officialSiteUrl: "", mediaText: "", seasonNameText: "", } const Works: React.FC<WorksProps> = ({ loading, works}) => { const classes = useStyles() const [open, setOpen] = useState(false) const [work, setWork] = useState<Work>(initialWorkState) const handleOpen = () => { setOpen(true) } const handleClose = () => { setOpen(false) } return ( <React.Fragment> <Grid container spacing={4}> <WorkDetail work={work} open={open} handleClose={handleClose} /> { loading ? <CircularProgress className={classes.circularProgress}/> : works != null && works.length >= 1 && works.map((work) => ( <Grid item key={work.id} xs={12} sm={6} md={4}> <Card className={classes.card}> <CardMedia component="img" className={classes.cardMedia} // 画像がなかった場合は「NO IMAGE」を表示(各自用意してpublicディレクトリ以下に配置) src={work.image ? work.image : "/no_image.png"} onError={(e: any) => { e.target.src = "/no_image.png" }} onClick={() => { handleOpen() setWork(work) }} /> <CardActions className={classes.cardActions}> { work.seasonNameText != null && ( <Chip label={work.seasonNameText} variant="outlined" /> ) } { work.mediaText != null && ( <Chip label={work.mediaText} variant="outlined" /> ) } { work.officialSiteUrl != null && ( <Chip label="公式サイト" component="a" rel="noopener noreferrer" href={work.officialSiteUrl} target="_blank" clickable color="secondary" variant="outlined" /> ) } { work.twitterUsername != null && ( <Chip label="Twitter" component="a" rel="noopener noreferrer" href={`https://twitter.com/${work.twitterUsername}`} target="_blank" clickable color="primary" variant="outlined" /> ) } </CardActions> <CardContent> <Typography variant="h3" gutterBottom> {work.title} </Typography> </CardContent> </Card> </Grid> )) } </Grid> </React.Fragment> ) } export default Works ./src/App.tsx import React, { useEffect, useState } from "react" import { makeStyles, ThemeProvider } from "@material-ui/core/styles" import Container from "@material-ui/core/Container" import Header from "components/layouts/Header" import Works from "components/work/Works" import SelectBox from "components/work/SelectBox" import theme from "components/utils/theme" import { getWorks } from "lib/api/works" import { Work } from "interfaces/index" const useStyles = makeStyles(() => ({ container: { marginTop: "2rem" } })) const years: Array<{ value: number label: string }> = [] // 現在の年を取得 const currentYear: number = new Date().getFullYear() for (var y = currentYear; y >= 1970; y--) { years.push({ value: y, label: `${y}` }) } const seasons: Array<{ value: number label: string }> = [ { value: 1, label: "春" }, { value: 2, label: "夏" }, { value: 3, label: "秋" }, { value: 4, label: "冬" } ] // 現在の季節を取得 const currentSeason: number = seasons[(Math.ceil((new Date().getMonth() +1 ) / 3)) - 2].value const App: React.FC = () => { const classes = useStyles() const [loading, setLoading] = useState<boolean>(true) const [works, setWorks] = useState<Work[]>([]) const handleGetWorks = async (year?: number, season?: number, title?: string): Promise<void> => { const res = await getWorks(year, season, title) if (res.status === 200) { setWorks(res.data.works) } setLoading(false) } // デフォルトでは現在の年・季節の作品を取得 useEffect(() => { handleGetWorks(currentYear, currentSeason) }, []) return ( <React.Fragment> <ThemeProvider theme={theme}> <Header handleGetWorks={handleGetWorks} setLoading={setLoading}/> <Container className={classes.container} maxWidth="lg"> <SelectBox years={years} seasons={seasons} handleGetWorks={handleGetWorks} setLoading={setLoading} /> <Works works={works} loading={loading}/> </Container> </ThemeProvider> </React.Fragment> ) } export default App 動作確認 最終的にこんな感じになっていれば完成です。 番外編(データベースの定期更新) ここから先は番外編なので興味の無い人は読み飛ばしてOKです。 もし今回作成したアプリを本格的に使い続けたい場合、定期的に情報を更新するバッチ処理などを実装する必要があるでしょう。(アニメ作品はこれからも続々と追加されていくため) そこで一応、データベースの定期更新について自分なりの手順を記しておきます。 sidekiqをインストール 今回はRailsアプリに定期実行を組み込む際に定番の sidekiq というgemを使っていきたいと思います。 ./Gemfile gem 'sidekiq' gem 'sidekiq-cron' Gemfileを更新したので再度ビルド。 $ docker-compose build Workerクラスを作成 定期実行用のWorkerクラスを作成します。 $ docker-compose run api rails g sidekiq:worker Test $ docker-compose run api rails g sidekiq:worker WorkImport $ docker-compose run api rails g sidekiq:worker WorkDetailImport ./app/workers/test_worker.rb class TestWorker include Sidekiq::Worker # 動作確認用 def perform puts "Hello World!" end end ./app/workers/work_import_worker.rb class WorkImportWorker include Sidekiq::Worker # Annictから情報を取得 def perform Work.new.import_from_annict end end ./app/workers/work_detail_import_worker.rb class WorkDetailImportWorker include Sidekiq::Worker # しょぼいカレンダーから情報を取得 def perform WorkDetail.new.import_from_syobocal end end 各種設定 $ touch config/initializers/sidekiq.rb config/sidekiq.yml config/schedule.yml ./config/initializers/sidekiq.rb # Redisの設定 Sidekiq.configure_server do |config| config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") } end Sidekiq.configure_client do |config| config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379")} end # どのタイミングで定期実行を行うかを記述したファイルを読み込む schedule_file = "config/schedule.yml" if File.exist?(schedule_file) && Sidekiq.server? Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) end ./config/sidekiq.yml :verbose: false :pidfile: ./tmp/pids/sidekiq.pid :concurrency: 25 :queues: - default ./config/schedule.yml test: cron: "*/5 * * * *" # 5分おきに実行 class: "TestWorker" queue: default work_import: cron: "0 0 * * 1" # 毎週月曜日の午前0時に実行 class: "WorkImportWorker" queue: default work_detail_import: cron: "0 0 * * 1" # 毎週月曜日の午前0時に実行 class: "WorkDetailImportWorker" queue: default ./config/application.rb # 以下3行を適当な場所に追記(sidekiqのダッシュボードを見るために必要) # https://edgeguides.rubyonrails.org/api_app.html#using-session-middlewares config.session_store :cookie_store, key: '_interslice_session' config.middleware.use ActionDispatch::Cookies config.middleware.use config.session_store, config.session_options ./config/routes.rb require "sidekiq/web" require "sidekiq/cron/web" Rails.application.routes.draw do mount Sidekiq::Web, at: "/sidekiq" # ダッシュボードへのルーティング namespace :api do namespace :v1 do resources :test, only: %i[index] resources :works, only: %i[index] end end end ./docker-compose.yml version: "3" services: db: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: password volumes: - mysql-data:/var/lib/mysql - /tmp/dockerdir:/etc/mysql/conf.d/ ports: - 4306:3306 api: build: context: . dockerfile: Dockerfile command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" volumes: - .:/myapp - ./vendor/bundle:/myapp/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development REDIS_URL: redis://redis:6379 # 追記 ports: - "3001:3000" depends_on: - db redis: # 追記 image: redis:6.0-alpine volumes: - redis:/data command: redis-server --appendonly yes worker: # 追記 build: . environment: RAILS_ENV: development REDIS_URL: redis://redis:6379 volumes: - .:/myapp depends_on: - redis command: bundle exec sidekiq -C config/sidekiq.yml volumes: mysql-data: redis: # 追記 動作確認 設定の変更を反映させるためにコンテナを再起動させます。 $ docker-compose down $ docker-compose up -d http://localhost:3001/sidekiq にアクセスして良い感じのダッシュボードが表示されればOK。 「cron」タブを開いてみると、先ほど作成した定期実行がスケジューリングされています。 $ docker-compose logs -f worker worker_1 | 2021-07-23T20:37:18.682Z pid=1 tid=go7s74505 INFO: Cron Jobs - add job with name: work_detail_import worker_1 | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Booted Rails 6.1.4 application in development environment worker_1 | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Running in ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux] worker_1 | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: See LICENSE and the LGPL-3.0 for licensing details. worker_1 | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Upgrade to Sidekiq Pro for more features and support: https://sidekiq.org worker_1 | 2021-07-23T20:40:07.738Z pid=1 tid=go7ssy92d class=TestWorker jid=be181a712bacc7ece7d0d7c7 INFO: start worker_1 | Hello World! worker_1 | 2021-07-23T20:40:10.093Z pid=1 tid=go7ssy92d class=TestWorker jid=be181a712bacc7ece7d0d7c7 elapsed=2.353 INFO: done worker_1 | 2021-07-23T20:45:18.099Z pid=1 tid=go7sopr55 class=TestWorker jid=82dccac35bf6b3b56cd2d701 INFO: start worker_1 | Hello World! worker_1 | 2021-07-23T20:45:18.103Z pid=1 tid=go7sopr55 class=TestWorker jid=82dccac35bf6b3b56cd2d701 elapsed=0.004 INFO: done 「docker-compose logs」コマンドでログを確認し、5分おきに「Hello World!」と出力されれば無事動いていると考えて大丈夫です。その他も時が来ればしっかりと実行されるはず。 あとがき 以上、Annict様としょぼいカレンダー様の力を借りて俺流アニメデータベースを作ってみました。 やはり自分で作ったアプリというのは愛着が湧くものなので、今後視聴するアニメを選ぶ際などに利用したいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

storybook next/image エラー解決:next.config.jsにドメイン設定してもダメ

前提 環境 package.json "dependencies": { "next": "^11.0.1", "react": "17.0.2", "react-dom": "17.0.2" }, "devDependencies": { "@babel/core": "^7.14.8", "@storybook/addon-actions": "^6.3.5", "@storybook/addon-essentials": "^6.3.5", "@storybook/addon-links": "^6.3.5", "@storybook/addon-postcss": "^2.0.0", "@storybook/react": "^6.3.5", "@testing-library/react": "^12.0.0", "@types/jest": "^26.0.24", "@types/node": "^15.6.1", "@types/react": "^17.0.6", "@types/react-dom": "^17.0.5", "@typescript-eslint/eslint-plugin": "^4.28.4", "autoprefixer": "^10.3.1", "babel-jest": "^27.0.6", "babel-loader": "^8.2.2", "eslint": "^7.31.0", "eslint-config-next": "^11.0.1", "eslint-config-prettier": "^8.3.0", "husky": "^7.0.1", "identity-obj-proxy": "^3.0.0", "jest": "^27.0.6", "jest-watch-typeahead": "^0.6.4", "lint-staged": "^11.1.0", "postcss": "^8.3.6", "prettier": "^2.3.2", "tailwindcss": "^2.2.6", "typescript": "^4.2.4" } エラー内容 Next.jsでコンポーネントを開発していると、Storybookで以下のエラーに遭遇しました。 next/image component, you passed a src value that uses a hostname in the URL that isn't defined in the images config in next.config.js. これを見ると、どうやらnext/imageコンポーネントを活用するページで、next.config.jsのimagesで定義されていないURLをsrcに使用することはできないようです。 しかし、next.config.jsを作成し、上記のリンクのように設定してもエラーは解決しませんでした。 touch next.config.js next.config.js module.exports = { images: { domains: ['example.com'], }, }; 解決した方法 .storybook/preview.jsに以下を追加 import * as nextImage from 'next/image'; Object.defineProperty(nextImage, 'default', { configurable: true, value: props => <img {...props} /> }); 修正後 (.storybook/preview.js) import '../src/styles/globals.css'; import * as nextImage from 'next/image'; Object.defineProperty(nextImage, 'default', { configurable: true, value: props => <img {...props} /> }); export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }; 参照
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで別のcomponent中の特定箇所にlinkを貼る。

問題 | ReactでSPAを作ったとき、ページ内の特定の箇所に飛ばせたかったのでanchorを使ったが、別コンポーネントからは効かなかった。 解決策 | react-router-hash-linkで行けた! : 備忘録も兼ねて詳しくどこをどうしたかを記します。 状況説明 Reactで"/"というURLにアクセスしたときはHomeというコンポーネントを、"/foo"にアクセスしたときはFooを、"/Hoge"にアクセスしたおきはFogeを表示するようにreact-router-domを使ってルーティングを以下のように設定。 App.js import React from 'react'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import Foo from './components/Foo'; import Hoge from './components/Hoge'; function App() { return ( <BrowserRouter> <Switch> <Route exact path="/"> <Home /> </Route> <Route path="/foo"> <Foo /> </Route> <Route path="/hoge"> <Hoge /> </Route> <Route>Not Found</Route> </Switch> <GlobalStyles /> </BrowserRouter> ); } さらに各コンポーネントに、以下のナビゲーション内容を持つHeaderというコンポーネントをimport。(Linkでもいいのですが、選んだ時にactiveStyleを適用できるよう、NavLinkを使っています。) import React from 'react'; import { NavLink } from 'react-router-dom'; <NavLink activeStyle={{ borderBottom: '1px solid black' }} exact to="/" > Home </NavLink> <NavLink activeStyle={{ borderBottom: '1px solid black' }} exact to="/foo" > Foo </NavLink> <NavLink activeStyle={{ borderBottom: '1px solid black' }} exact to="/hoge" > Hoge </NavLink>  Homeコンポーネント中のAboutという箇所に各コンポーネントのHeaderから飛べるようにしたい! anchorはHome以外のコンポーネントからは効かないので調べると、react-router-hash-linkが使えることが判明。そこで、、、 yarn add react-router-hash-linkでdependencyを追加。 Headerの中に、NavHashLinkを使ってAboutを追加。 スムーズに移動できるので、smoothというプロパティを追加。 App.jsは変更不要。 飛ばしたい先の要素にid="about"とつける。 Header.js import { NavHashLink } from 'react-router-hash-link'; ←importする。 <NavLink activeStyle={{ borderBottom: '1px solid black' }} exact to="/" > Home </NavLink> <NavHashLink                       ←ここを追加。 smooth activeStyle={{ borderBottom: '1px solid black' }} to="/#about" > About </NavHashLink> {/* 以下省略 */} これで、別のコンポーネントから、Homeコンポーネント中の特定箇所に飛ぶようになりました。 今回もいろいろなサイトを参考にさせていただきました。ありがとうございました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む