実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その24)
0. 初めに
こんにちは!
このシリーズでは、見習いエンジニアの僕が一からWebアプリケーションを開発する様子をお届けしています!
2週間もお休みしてすみませんでした!!
また、モチベが下がりつつあります💦
やはり、一つのアプリを何か月も作るとモチベが下がりますね。
ですが、最近ようやく復活しつつあるので、今後にご期待ください!
前回の続きということで、今日もフロントエンドを実装していきます!
今回は、サイドバーを作成したいと思います!
サイドバーとは
その名の通り、画面の左右どちらかに表示されているもので、「ログイン」や「ログアウト」、「マイページへの遷移」などの便利だけど、ヘッダーに乗せるほどではないメニューを選べるものっていうイメージです。
例: YouTube
https://www.youtube.com/

1. ブランチ運用
今日も、ローカルのdevelopを最新化して、新規ブランチを切って作業します。
ブランチ名は、feature/frontend/layout-base/sidebarにしましょう。
作業が完了したら、コミット・プッシュを忘れずに行いましょう!
2. 画面デザイン
例によって、今日の完成のイメージを持っておきましょう。
なぜ二つあるかと言いますと、ログインしている状態とログアウトしている状態でメニューを変化させたかったからです。
3. ログイン状態共有
そのためには、ログイン・ログアウトの状態の情報をReact側に共有しておく必要があります。
これまでと同様に、Laravelのコントローラーでページごとにpropsとして渡すことも可能ですが、もっと良い方法があります。
React全体でユーザー情報を共有して使えるようにする方法があります。
というのも、今日作るサイドバー以外でもログインしているときとしていないときで表示を切り替えたい場面って今後たくさん出てきそうですよね。
そのたびごとにそのページたちに対して毎回対応するコントローラーに同じpropsで渡す処理を書くのは大変です。
それを実現する方法が、Middleware\HandleInertiaRequests.phpのshare()メソッドを利用する方法です。
詳しくはこちらをご覧ください。
https://inertiajs.com/shared-data
実は、バックエンド編でInertiaをセットアップした際に自動で作られているのですが、一応以下のようなメソッドがあることを確認してください。
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
],
];
}
これは、React側からLaravel側に対してリクエストをすると共有データとして登録されている配列にあるデータをReact側のどのページからも参照することができるようにするものです。
既に、'auth'に'user'という値が設定されています。
これにより、React側でauth.userのようにすることで、ユーザー情報を取得できます。
また、...parent::share($request),の部分についてですが、これは継承元のmiddlewareの配列の展開(スプレッド構文)であり、middlewareはLaravel, Inertia側で既に用意されているものであるため、中身を深く理解する必要はなさそうです。
4. AppLayoutでの状態管理
次に、AppLayoutでユーザーのログイン・ログアウト及びサイドバーの開閉状態を管理するように修正したいと思います。
ここからようやくReactっぽいことをしていきたいと思います。
つまり、React Hooksを用いた状態管理をしていきたいと思います!
React Hooksとは、状態管理をするための関数(厳密には異なりますが)みたいなものだと思ってもらえればよいかなと思います。
そして、状態管理というのは、今回で言うと「サイドバーが開いている or 閉じている」というような画面上で持っておくべき情報を保存しておき、それを利用して何か画面の様子を切り替えたり、動かしたりするためのものです。
例えばYouTubeであれば、「再生ボタンが押されている or ストップボタンが押されている」とか、カウンターアプリなら「現在のカウント数」とかを管理するのに利用されています。
そんなReact Hooksの中でも一番有名なもので、今日も使用する予定のuseStateというやつがあります!
今日は、そのほかにも、useEffectとuseCallbackを使いたいと思います。
初めての人やまだ慣れていない人は以下の記事に目を通しておくとよいでしょう!
とても分かりやすかったです。
僕もuseCallbackは正直良くわかっていなかったので、全部読みました。(笑)
https://envader.plus/article/443
https://envader.plus/article/444
https://envader.plus/article/497
では、状態管理をするために、React Hooksをインポートしましょう!
import { useState, useCallback, useEffect } from 'react'; // 追加
さらに、Sidebarを追加します!
これは、後で作るので今はエラーが出ますが、気にしないでください。
import Sidebar from "../Components/Sidebar"; // 追加: 後で作る
では、いよいよ中身を書いていきます!
今回管理したい状態は2つです。
- ユーザーがログインしているかどうか
- サイドバーが開いているかどうか
まず、ユーザーのログイン状態についてです。
ユーザーの情報は、InertiaのShared dataで定義されていれば、usePage()を使えば取得できます!
それをもとに、ログインしているか・ログアウトしているかの情報を真偽値(true or false)で管理します。
それをisLoggedInという変数に代入しておきます。
export default function AppLayoutの中のreturnの上に書いていきましょう。
export default function AppLayout({ children, title }) {
const { props } = usePage(); // 追加
const isLoggedIn = !!props?.auth?.user; // 追加
return (
// ...
);
}
isLoggedInは、ログインしていればtrueに、ログアウトしていればfalseになります。
!!は、論理否定演算子(!)を二つ連ねたものです。
!は、真偽値をひっくり返すものでした。
では、なぜ二重にするのかと言いますと、JavaScriptの特性があるからです。
JavaScriptでは、undefinedや0、''といった値を自動でfalseに変換してくれます。
これらは、falsyな値と呼ばれます。
falseという値をそのまま明示的代入したい場合は、!で一度trueにしてから再度!でひっくり返すというワザがあるらしいです。
まとめてくれている記事があったのでご紹介しておきますね。
https://zenn.dev/hosoiroji/articles/double-exclamation-mark
次に、サイドバーの開閉状態についてです。
先ほど追加したログイン状態管理のHooksの下に追加しておきます。
export default function AppLayout({ children, title }) {
const { props } = usePage();
const isLoggedIn = !!props?.auth?.user;
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 追加: サイドバーの開閉状態
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []); // 追加: サイドバーの開閉を設定
return (
// ...
);
}
ついに出ました!useState()です。
ちょっと独特な書き方をしますが、状態管理をしたい値を〇〇という変数に代入するよう各場合、set○○という状態を更新するための関数も一緒に定義します。
その際、useState()のかっこの中に初期値を渡します。
const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 追加: サイドバーの開閉状態
今回の場合だと、サイドバーが開いているかどうかを真偽値で管理したいので、それをisSidebarOpenという値に代入します。
最初は、閉じている状態から始めたいので、初期値としてfalseを渡しています。
そして、開閉状態を切り替えたい場合は、setIsSidebarOpenを呼び出せばよいということになります!
※ちなみに、Reactでは、状態管理をしている変数に更新したい値を直接代入することは禁止されています。
確かに、setIsSidebarOpenとかいうよくわからん回りくどい書き方をしなくても、例えばサイドバーを開きたいときは以下のように直接isSidebarOpenにtrueを代入すればよくね?と思ってしまいます。
isSidebarOpen = true
しかし、constで定義している以上、そもそもこの書き方はエラーが起きてしまいます。
また、それよりもいろいろと奥が深い理由があるらしいです。
興味のある方はぜひ調べてみてください。
この記事では、そこまで深入りはしません。
React初心者の方はとりあえず、直接代入は禁止なんだと覚えておいてもらえればよいと思います!
useStateでは、stateに更新したい値を直接代入してはならない。
そして、このsetIsSidebarOpenの呼び出し方ですが、今回はsetSidebarOpenという別の関数をuseCallback経由で用いることで呼び出してみたいと思います。
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []); // 追加: サイドバーの開閉を設定
引数oepnに真偽値を代入して使いたいと思います!
初回レンダリング時に読み込まれればよいので、依存配列は空にしておきましょう。
依存配列については後で軽く解説を入れるので、よくわからない場合はいったん飛ばしてもらっても大丈夫です。
では、今作った状態管理を利用したjsxをreturn文の中に追加して使っていきましょう。
export default function AppLayout({ children, title }) {
// ユーザーの認証状態を管理
const { props } = usePage();
const isLoggedIn = !!props?.auth?.user;
// サイドバーの開閉状態を管理
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);
return (
<div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
<Head title={title} />
{/* Header 追加: isSidebarOpen */}
<Header title={title} onOpenSidebar={() => setSidebarOpen(true)} />
{/* Main Content Area */}
<main className="flex-1 bg-transparent flex">
<div
className="
mx-auto
w-full
max-w-container
px-[clamp(16px,4vw,32px)]
py-6
bg-[#EEF5F9]
flex-1
"
>
{children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
</div>
</main>
{/* 追加: サイドバー */}
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
</div>
);
}
サイドバーは前回作ったヘッダー内のハンバーガーアイコンをクリックしたときに開くようにしたいので、開閉情報をヘッダーに渡す必要があります。
{/* Header 追加: isSidebarOpen */}
<Header title={title} onOpenSidebar={() => setSidebarOpen(true)}
Header側からonOpenSidebarというpropsを受け取れるようにして(この後実装します)、それに対して先ほど作ったsetSidebarOpen()を渡します。
ハンバーガーアイコンがクリックされるとヘッダーが開くようにしたいので、当然isSidebarOpenをtrueにする必要があるため、setSidebarOpen(true)のようにします!
さらにサイドバーに対して、閉じるボタンを用意してクリックできるようにしたいので、当然サイドバーに対しても開閉状態を渡す必要があります。
また、ログイン状態によって表示内容を変えたいので、ユーザーの認証状態も渡します。
{/* 追加: サイドバー */}
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
サイドバー(この後作成します)には、開閉状態を管理するisOpenと閉じる操作を起こすonClose、認証状態を管理するisLoggedInという三つを受け取るpropsを用意していおいて、それらに対してそれぞれisSidebarOpen、() => setSidebarOpen(false)、isLoggedInを渡します。
これにて、\Layouts\AppLayout.jsxの修正はいったん完了です!
5. Header修正
次に、\Components\Header.jsxを修正していきたいと思います。
と言ってもやることは簡単で、先ほどAppLayoutから渡すようにしたonOpenSidebarを受け取って、ハンバーガーアイコンがクリックされたときにそれが発動するようにしてあげればOKです!
import logo from '../Assets/logo/header.svg';
import hamburgerIcon from '../Assets/icons/hamburger.svg';
import { Link } from '@inertiajs/react';
const Header = ({ title, onOpenSidebar }) => { // 追加: onOpenSidebar を受け取る
return (
<header
className="
relative
h-14
flex
items-center
justify-between
px-6
bg-[#EEF5F9]
after:content-['']
after:absolute
after:bottom-0
after:left-0
after:h-[3px]
after:w-full
after:bg-[linear-gradient(to_right,rgba(226,145,140,0.8),rgba(215,145,232,0.8),rgba(118,192,235,0.8))]
"
>
{/* 左:ロゴ */}
<div className="flex items-center">
<Link href={route('labs.home')} aria-label="トップページへ">
<img src={logo} alt="App Logo" className="h-9 w-auto" />
</Link>
</div>
{/* 中央:タイトル */}
<h1 className="absolute left-1/2 -translate-x-1/2 text-xl font-semibold text-black">
{title}
</h1>
{/* 右:ハンバーガー onClickにonOpenSidebarを追加 */}
<button
className="ml-auto flex items-center"
aria-label="メニューを開く"
onClick={onOpenSidebar}
>
<img
src={hamburgerIcon}
alt="メニュー"
className="h-7 w-7 hover:opacity-80 transition"
/>
</button>
</header>
);
};
export default Header;
6. Sidebar作成
続いて、サイドバーを作ります。
ここが今日のメインです!
Header.jsxと同じ階層にSidebar.jsxというファイルを新規作成してください。
ファイルを作成出来たら、まず、使用するアイコンたちを呼び出しましょう。
クリック時にリンクを移動させたいので、Linkも一緒にインポートします。
import { Link } from '@inertiajs/react';
import close from '../Assets/icons/sidebar/close.svg';
import home from '../Assets/icons/sidebar/home.svg';
import mypage from '../Assets/icons/sidebar/mypage.svg';
import bookmark from '../Assets/icons/sidebar/bookmark.svg';
import ranking from '../Assets/icons/sidebar/ranking.svg';
import logout from '../Assets/icons/sidebar/logout.svg';
import login from '../Assets/icons/sidebar/login.svg';
import register from '../Assets/icons/sidebar/register.svg';
これらのアイコンを共有しますので、ダウンロードして指定されたディレクトリに格納してください。
前回までに用意している\project-root\src\resources\js\Assets直下にsidebarというフォルダを作って、その中に以下のアイコンたちを格納してください。
今回もReact IconsとFigmaで作りました!
自分でも作ってみたいという方はぜひ挑戦してみてください!(; ・`д・´)
Viteを利用しているため、画像ファイルをまるでReactコンポーネントかのように扱えるのでした。
次にSidebar関数コンポーネントを定義・エクスポートしましょう。
AppLayoutから渡されるpropsたちを受け取れるようにします。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {}
中身を書いていきましょう。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// メニュー定義
const items = isLoggedIn
? [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: 'マイページ', href: route('mypage.index'), icon: mypage },
{ label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
{ label: 'ランキング', href: null, icon: ranking },
{ label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
]
: [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: '新規登録', href: route('register'), icon: register },
{ label: 'ログイン', href: route('login'), icon: login },
{ label: 'ランキング', href: null, icon: ranking },
];
}
サイドバーに表示するメニューをオブジェクト型で定義して、変数itemsに代入しています。
三項演算子を利用することで、受け取ったpropsのisLoggedInによって、内容を変えます。
これにより、ログイン・ログアウト状態で表示されるメニューを切り替えることができます!
三項演算子が怪しい方は復習しておきましょう。
JavaScriptの基本文法です。
(怪しい人だけ)三項演算子について復習しておきましょう。
オブジェクトの構成としては、label、href、method、iconの四つのプロパティを用意しました。
-
label: 表示する文字 -
href: 遷移先のリンク。LaravelのZiggyルートヘルパ※ -
method: メソッドの種類。ログアウトのみで使用 -
icon: 表示するアイコン。インポート時の名前を用いる
※バックエンド実装編で作成したweb.phpにおいて名前付きルーティングで定義したものがそのまま使える
ログアウト以外は、それぞれのページに遷移するだけで済みますが、ログアウト処理はPOSTメソッド通信が必要なので、それだけのためにmethodプロパティを用意しました。
ちなみに、ランキング機能は未実装です。
これがないとなんか寂しかったので付けました。(笑)
なので特に気にしないでください。
続いて、return分の中身を追加していきましょう。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// メニュー定義
const items = isLoggedIn
? [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: 'マイページ', href: route('mypage.index'), icon: mypage },
{ label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
{ label: 'ランキング', href: null, icon: ranking },
{ label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
]
: [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: '新規登録', href: route('register'), icon: register },
{ label: 'ログイン', href: route('login'), icon: login },
{ label: 'ランキング', href: null, icon: ranking },
];
return (
<>
{/* 本体 */}
<aside
role="dialog"
aria-modal="true"
className={`
fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
transform transition-transform duration-300
${isOpen ? 'translate-x-0' : 'translate-x-full'}
flex flex-col
`}
>
</aside>
</>
);
}
追加したものは、ひとまず三つです。
一つ目はこのサイドバー本体です。
こちらは、asideタグを使用しています。
「divタグでよくね?」と思ってしまいがちですが、結構メリットもあるようです。
詳しくは、以下を参照ください。
https://www.sejuku.net/blog/227859
role="dialog"とaria-modal="true"の部分は、例のアクセシビリティのスクリーンリーダーへの対応なので、そこまで気にしなくて大丈夫です。
className属性の中身を見てみましょう。
fixed right-0 top-0で、画面右上に張り付けることができます。
これにより、スクロールしても動かなくなります。
https://v3.tailwindcss.com/docs/position#fixed-positioning-elements
h-dvhで、高さを画面いっぱいに広げることができます。
https://v3.tailwindcss.com/docs/height#dynamic-viewport-height
開閉のアニメーション用にtransformを定義できます!
transition-transform duration-300で、300ms(0.3秒間ですね)でアニメーションが動きます。
これがないと一瞬でサイドバーが出たり消えたりしてしまいます。
${isOpen ? 'translate-x-0' : 'translate-x-full'}の部分では、今回二度目の三項演算子が使われています。
${ 変数名 }とするとJavaScriptの変数を展開できます。
基本文法なので、おそらく大丈夫ですよね..?
怪しいという方は復習ですね。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Template_literals
translate-x-0つまり閉じている状態から、translate-x-fullすなわち開いている状態に0.3秒間のアニメーションを付けることができます。
これにより、滑らかな動作が実現できます!
次に、ヘッダーの部分に重なるバツ印を追加しましょう。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// メニュー定義
// ...
return (
<>
{/* 本体 */}
<aside
role="dialog"
aria-modal="true"
className={`
fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
transform transition-transform duration-300
${isOpen ? 'translate-x-0' : 'translate-x-full'}
flex flex-col
`}
>
{/* ヘッダー(バツ印) */}
<div className="flex items-center justify-end px-4 h-14">
<button
type="button"
onClick={onClose}
className="p-2"
aria-label="メニューを閉じる"
autoFocus
>
<img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
</button>
</div>
</aside>
</>
);
}
onClick={onClose}と書くことで、クリックされたときにonCloseが呼ばれるようになります!
ちょっと処理の流れが分かりにくいかもしれないので、くどいかもしれませんが書いておきますね!
①ユーザーによって、バツ印がクリックされる
②onClickイベントが発火して、onCloseが呼びだされる!
onClick={onClose}
③onCloseはAppLayoutからpropsとして受け取っている。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {}
④AppLayoutでは、setSidebarOpenを返すコールバック関数を渡している。
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
⑤setSidebarOpen(false)により、setIsSidebarOpen(false)となるため、isSidebarOpenがfalseになる。
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);
⑥SidebarのisOpen propsに対して、それが渡される。これによりisOpenがfalseとなる。
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
⑦Sidebarでは、これを受け取り、三項演算子で開閉状態を表現する。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// ...
return (
<>
<aside
className= {`
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
>
// ...
</aside>
</>
);
}
propsは複数ファイルにまたがって、処理が流れていくため最初は分かりにくいかもしれません。
前回に引き続き、かなり詳し目に解説しましたが、いかがでしたでしょうか?
次回以降から少しずつ説明を省略していきますので、少しずつ慣れていきましょう!
autoFocusの部分は、マウスを使って操作する分にはそこまで大きな影響を与えるものではないので、気にしなくてOKです。
※余談(ちなみに...)
Reactってルールがいろいろあって難しく感じると思います。
そのうちの一つが、④の部分にあります。
それが、以下のように、コールバック関数を書かずに更新関数を直接渡すような書き方です。
onClose={setSidebarOpen(false)}
これについての解説は今回やろうとすると記事が長くなりすぎてしまうので、割愛しますが、今後余裕があるときにでも解説してみたいと思います!
ですが、気になる方はぜひ調べてみてください!(≧◇≦)
最後に、メニューのリストを追加しましょう。
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
return (
<>
{/* 本体 */}
<aside
role="dialog"
aria-modal="true"
className={`
fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
transform transition-transform duration-300
${isOpen ? 'translate-x-0' : 'translate-x-full'}
flex flex-col
`}
>
{/* ヘッダー(バツ印) */}
<div className="flex items-center justify-end px-4 h-14">
<button
type="button"
onClick={onClose}
className="p-2"
aria-label="メニューを閉じる"
autoFocus
>
<img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
</button>
</div>
{/* リスト */}
<nav className="p-2">
<ul className="space-y-1">
{items.map((it) => (
<li key={it.label}>
{it.href ? (
<Link
href={it.href}
method={it.method}
as={it.method ? 'button' : 'a'}
className="
text-expand text-lg w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
hover: transition
text-[#747D8C] font-medium
"
onClick={onClose}
>
<img src={it.icon} alt="" className="h-8 w-8" />
{it.label}
</Link>
) : (
<span
className="
w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
text-gray-400 font-medium cursor-not-allowed
"
>
<img
src={it.icon}
alt=""
className={`${it.label === 'ランキング' ? 'h-8 w-8' : 'h-5 w-5'} opacity-40`}
/>
{it.label}
</span>
)}
</li>
))}
</ul>
</nav>
</aside>
</>
);
}
ランキングは、未実装によりhrefがNullのため、三項演算子で場合分けしていてやや見づらいかもしれませんが、やっていることはシンプルにmap()でメニューのリストを順々に表示しているだけです!
ちなみに、ランキングはクリックできないようにしてあります!
これで、Sidebar.jsxは一旦完成です。(*´Д`)
7. その他追加
ここまででも十分機能するとは思いますが、より良いUI/UXのためにもう一工夫しましょう。
- 背景のスクロール防止
- Escキーでサイドバー閉鎖
- オーバーレイ設定
一工夫と言いつつ、三つもありますw
まずは、AppLayout.jsxにuseEffect()を二つ追加しましょう。
まず、背景のスクロール防止です。
useEffect(() => {
if (isSidebarOpen) document.body.classList.add('overflow-hidden');
else document.body.classList.remove('overflow-hidden');
return () => document.body.classList.remove('overflow-hidden');
}, [isSidebarOpen]);
useEffectを用いています!
その中で、if-else構文を使って、ページ全体のクラス属性に対して、'overflow-hidden'を、サイドバーが開いているときに追加・閉じているときに除外します。
これによって、サイドバーが開いているときだけ背景のスクロールが禁止されるようになります。
では、これらの処理がいつ起きるのかについて解説していきます。
それを担っているのが、先ほど解説を後回しにした依存配列です!
ユーザーがヘッダーのハンバーガーアイコンをクリックすることでサイドバーが開き、依存配列にあるisSidebarOpenがtrue変化します。
useEffectは、初回レンダリング時と依存配列に指定されている変数の値が変化するたび発動します。
よって、ユーザーがサイドバーを閉じるボタンを押すと、サイドバーが閉じ、再びisSidebarOpenが変化し、falseになります。
これにより、useEffectが発動します。
こうすることで、サイドバーの開閉に応じて、背景スクロールの可否を変化させることができます。
また、最後のreturn () => document.body.classList.remove('overflow-hidden');ですが、これはクリーンアップ関数と呼ばれるものです。
ちょ~簡単に言えば、このコンポーネントの役割が終わるときに発動するもので、これがないとうまく状態管理をクリーンにできず、メモリに思わぬ負荷がかかることがあります。
この辺の説明も次回以降の余裕がある回でもう少し詳しくお話しできたら良いなと思います。
今日のところは、「そういうのがあるんだ」くらいでも大丈夫です。
次に、Escキーでサイドバー閉鎖ですね。
こちらも、AppLayout.jsxに追加したuseEffectで処理しています。
useEffect(() => {
const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
e.keyは押されたキーボードのキーを表しています。
押されたキーボードのキーが'Escape'だったら、setIsSidebarOpen(false)が発動する(つまりサイドバーを閉じる)という処理です。
「あれ?でも依存配列の中身が空っぽだからいつ発動するの?」と思ったかもしれません。
実は、useEffectが発動するのは、依存配列の中にある変数の値が変わったときの他にコンポーネントがレンダリング(厳密には違いますが、まあページが表示されたときと思ってもらえれば分かりやすいかなと思います)されて、マウント(UIが実際にDOMに追加されること)されるときに発動します。
「「レンダリング」、「DOM」、「マウント」、「クリーンアップ関数」とか難しい言葉ばかり使うな!!」って感じっすよねw
難しく考えすぎると、沼にはまるので今日のところは、とりあえず、最初のコンポーネントが読み込まれるときと依存配列内の変数が変化した時に発動すると思えばよいと思います!
機会があれば、この辺の流れについて今度分かりやすくまとめてみたいと思います。
最終的なファイル全体は、以下のようになります!
import { Head, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import Header from "../Components/Header";
import Sidebar from "../Components/Sidebar";
export default function AppLayout({ children, title }) {
// ユーザーの認証状態を管理
const { props } = usePage();
const isLoggedIn = !!props?.auth?.user;
// サイドバーの開閉状態を管理
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);
// 追加: サイドバーが開いている間は、背景のスクロールを防止
useEffect(() => {
if (isSidebarOpen) document.body.classList.add('overflow-hidden');
else document.body.classList.remove('overflow-hidden');
return () => document.body.classList.remove('overflow-hidden');
}, [isSidebarOpen]);
// 追加: Escキーでサイドバーを閉じる
useEffect(() => {
const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
<Head title={title} />
{/* Header 追加: isSidebarOpen */}
<Header title={title} onOpenSidebar={() => setSidebarOpen(true)} />
{/* Main Content Area */}
<main className="flex-1 bg-transparent flex">
<div
className="
mx-auto
w-full
max-w-container
px-[clamp(16px,4vw,32px)]
py-6
bg-[#EEF5F9]
flex-1
"
>
{children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
</div>
</main>
{/* サイドバー */}
<Sidebar isOpen={isSidebarOpen} onClose={() => setSidebarOpen(false)} isLoggedIn={isLoggedIn} />
</div>
);
}
最後は、オーバーレイ設定です。
今度は、Sidebar.jsxにつ追加です。
以下のdivで本体を囲みます。
{/* オーバーレイ */}
<div
onClick={onClose}
className={`
fixed inset-0 bg-black/40 transition-opacity duration-300
${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
aria-hidden="true"
/>
ここでやっていることは、実は二つあります。
一つ目は、サイドバーが開いているときにサイドバー以外の部分をクリックするとサイドバーが閉じるようにすることです。
いくらと閉じるためのバツ印ボタンがあるとはいえ、そこまでマウスを動かしてカーソルを合わせるのが面倒という場合もあると思うので、より親切な設計と言えそうです!
onClickイベントで、onCloseが発動するようにします。
ただ、サイドバー以外のどこでも発動するように、サイドバー本体であるasideタグの外側にdivで囲み、fixed inset-0を設定します。
fixedとinsetは今後も使う(多分だけどw)と思うので、ぜひ調べてみてください!
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/position
https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/inset
最終的なファイルは以下の通りです!
import { Link } from '@inertiajs/react';
import close from '../Assets/icons/sidebar/close.svg';
import home from '../Assets/icons/sidebar/home.svg';
import mypage from '../Assets/icons/sidebar/mypage.svg';
import bookmark from '../Assets/icons/sidebar/bookmark.svg';
import ranking from '../Assets/icons/sidebar/ranking.svg';
import logout from '../Assets/icons/sidebar/logout.svg';
import login from '../Assets/icons/sidebar/login.svg';
import register from '../Assets/icons/sidebar/register.svg';
export default function Sidebar({ isOpen, onClose, isLoggedIn }) {
// メニュー定義
const items = isLoggedIn
? [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: 'マイページ', href: route('mypage.index'), icon: mypage },
{ label: 'ブックマーク', href: route('mypage.bookmarks'), icon: bookmark },
{ label: 'ランキング', href: null, icon: ranking },
{ label: 'ログアウト', href: route('logout'), method: 'post', icon: logout },
]
: [
{ label: 'ホーム', href: route('labs.home'), icon: home },
{ label: '新規登録', href: route('register'), icon: register },
{ label: 'ログイン', href: route('login'), icon: login },
{ label: 'ランキング', href: null, icon: ranking },
];
return (
<>
<style>{`
@keyframes text-expand {
0% {
letter-spacing: 0;
}
100% {
letter-spacing: 0.05em;
}
}
.text-expand:hover {
animation: text-expand 0.3s ease-out forwards;
}
`}</style>
{/* オーバーレイ */}
<div
onClick={onClose}
className={`
fixed inset-0 bg-black/40 transition-opacity duration-300
${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
aria-hidden="true"
/>
{/* 本体 */}
<aside
role="dialog"
aria-modal="true"
className={`
fixed right-0 top-0 h-dvh w-[270px] max-w-[90vw] bg-[#EEF5F9] shadow-2xl
transform transition-transform duration-300
${isOpen ? 'translate-x-0' : 'translate-x-full'}
flex flex-col
`}
>
{/* ヘッダー(バツ印) */}
<div className="flex items-center justify-end px-4 h-14">
<button
type="button"
onClick={onClose}
className="p-2"
aria-label="メニューを閉じる"
autoFocus
>
<img src={close} alt="" className="h-7 w-7 hover:opacity-80 transition" />
</button>
</div>
{/* リスト */}
<nav className="p-2">
<ul className="space-y-1">
{items.map(it => (
<li key={it.label}>
{it.href ? (
<Link
href={it.href}
method={it.method}
as={it.method ? 'button' : 'a'}
className="
text-expand text-lg w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
hover: transition
text-[#747D8C] font-medium
"
onClick={onClose}
>
<img src={it.icon} alt="" className="h-8 w-8" />
{it.label}
</Link>
) : (
<span
className="
w-full text-left flex items-center gap-3 px-4 py-3 rounded-lg
text-gray-400 font-medium cursor-not-allowed
"
>
<img
src={it.icon}
alt=""
className={`${it.label === 'ランキング' ? 'h-8 w-8' : 'h-5 w-5'} opacity-40`}
/>
{it.label}
</span>
)}
</li>
))}
</ul>
</nav>
</aside>
</>
);
}
8. ログアウト中の動作確認(他ページにAppLayoutを適用)
動作確認をしたいのですが、現状だと共通レイアウトは、マイページにしか適用していません。
マイページはログインしていないとそもそも開けませんね。
よって、動作確認は、ログアウト状態でも開けるページにも共通レイアウトを適用してからにしましょう。
大学一覧ページのコンポーネント内で、AppLayoput.jsxをインポートして、全体を囲みましょう。
import React, { useState } from 'react';
import { Head, router, Link } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';
export default function Index({ universities, query }) {
const [search, setSearch] = useState(query || '');
const handleSubmit = e => {
e.preventDefault();
router.get('/universities', { query: search });
};
// ページネーション用の関数
const goToPage = page => {
const params = { page };
if (query) {
params.query = query;
}
router.get('/universities', params);
};
const goToPreviousPage = () => {
if (universities.current_page > 1) {
goToPage(universities.current_page - 1);
}
};
const goToNextPage = () => {
if (universities.current_page < universities.last_page) {
goToPage(universities.current_page + 1);
}
};
const goToFirstPage = () => {
goToPage(1);
};
const goToLastPage = () => {
goToPage(universities.last_page);
};
// ページ番号の配列を生成(現在のページ前後2ページずつ表示)
const getPageNumbers = () => {
const current = universities.current_page;
const last = universities.last_page;
const pages = [];
let start = Math.max(1, current - 2);
let end = Math.min(last, current + 2);
// 最初の方のページの場合、後ろを多めに表示
if (current <= 3) {
end = Math.min(last, 5);
}
// 最後の方のページの場合、前を多めに表示
if (current >= last - 2) {
start = Math.max(1, last - 4);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
const pageNumbers = getPageNumbers();
return (
<div>
<Head title="大学検索結果" />
<AppLayout>
<h1>大学検索</h1>
<div>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSubmit(e);
}
}}
placeholder="大学名で検索"
/>
<button onClick={handleSubmit}>検索</button>
</div>
{query && (
<p>
「<strong>{query}</strong>」の検索結果({universities.total}件)
</p>
)}
{universities.data.length === 0 ? (
<p>該当する大学は見つかりませんでした。</p>
) : (
<ul>
{universities.data.map(university => (
<li key={university.id}>
<Link href={route('faculties.index', university.id)}>{university.name}</Link>
</li>
))}
</ul>
)}
{/* ページネーション */}
{universities.last_page > 1 && (
<div>
<p>
ページ {universities.current_page} / {universities.last_page}
(全 {universities.total} 件中 {universities.from} - {universities.to} 件目)
</p>
<div>
{/* 最初のページボタン */}
<button onClick={goToFirstPage} disabled={universities.current_page === 1}>
≪
</button>
{/* 前のページボタン */}
<button onClick={goToPreviousPage} disabled={universities.current_page === 1}>
<
</button>
{/* 最初のページ番号より前に省略がある場合 */}
{pageNumbers[0] > 1 && (
<>
<button onClick={() => goToPage(1)}>1</button>
{pageNumbers[0] > 2 && <span>...</span>}
</>
)}
{/* ページ番号ボタン */}
{pageNumbers.map(pageNum => (
<button
key={pageNum}
onClick={() => goToPage(pageNum)}
disabled={pageNum === universities.current_page}
>
{pageNum}
</button>
))}
{/* 最後のページ番号より後に省略がある場合 */}
{pageNumbers[pageNumbers.length - 1] < universities.last_page && (
<>
{pageNumbers[pageNumbers.length - 1] < universities.last_page - 1 && (
<span>...</span>
)}
<button onClick={() => goToPage(universities.last_page)}>
{universities.last_page}
</button>
</>
)}
{/* 次のページボタン */}
<button
onClick={goToNextPage}
disabled={universities.current_page === universities.last_page}
>
>
</button>
{/* 最後のページボタン */}
<button
onClick={goToLastPage}
disabled={universities.current_page === universities.last_page}
>
≫
</button>
</div>
</div>
)}
</AppLayout>
</div>
);
}
ログイン状態・ログアウト状態で確認してみてください。
URLは以下ですね。
http://localhost/universities
9. まとめ・次回予告
お疲れ様でした!
今回は、サイドバーを作りました。
初めてのReact Hooksとして、useStateやuseEffectを使用しました。
ですが、これらの使い分けや細かい使い方・仕組みなどについては、記事のボリュームの都合上いったんは割愛しました。
今後、少しずつ慣れていってもらえれば分かるようになっていくと思います...!!
また、propsについては、詳し目に解説を入れました。
少し説明が冗長に感じた方もいらっしゃるかもしれませんね。('_')
次回以降は、少しずつ解説を短くしていくつもりなので、もし今後分からないことが出てきたら、今回の内容に立ち戻って復習してみてください。(*´Д`)
では、また次回お会いしましょう!
コミット・プッシュをお忘れなく!
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
- その2: 環境構築編① ~WSL, Ubuntuインストール~
- その3: 環境構築編② ~Docker Desktopインストール~
- その4: 環境構築編③ ~Dockerコンテナ立ち上げ~
- その5: 環境構築編④ ~Laravelインストール~
- その6: 環境構築編⑤ ~Gitリポジトリ接続~
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
参考
- 【React入門】useStateの使い方|動的なコンポーネント作成の第一歩
- Reactの基本、useEffectを理解しよう
- Reactの基本、useCallbackを理解しよう
- ビックリマーク2つの正体!! Double exclamation mark !!
- html asideタグとは?正しい使い方やサンプルコードを紹介
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。
現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"



