はじめに
未経験採用から半年の新人エンジニアである私が、Web スコアボードアプリを作成しました。
未経験での採用から約半年、React について勉強させていただいたり、Liquid を使った Shopify アプリの開発をしたりしてきました。
最近は、React、Next.js、TypeScript を使って簡単な Web アプリを開発しています。
そして、今回は Web スコアボードアプリを開発させていただくことになりました。
この記事では、このアプリの開発に至った経緯から、実装内容、反省点について書いていきます。
Web スコアボードを開発することになった経緯
スコアボードアプリを作ることになった経緯から書いていきます。
それは、複数チームで使える良い感じの Web スコアボードアプリが見当たらなかったからです。
弊社では、余暇時間に「モルック」をする文化があります。
「モルック」というのは、12 本の木の棒を立てて並べ、離れたところから別の棒を投げて倒し、点数を競う屋外競技です。個人的には、キャンパーがよくやっているイメージですね。
モルックをプレイする際 2 〜 3 チームに分かれるのですが、持ち合わせのスコアボードでは 2 チームまでしか対応できません。なので、3 チームでプレイする場合、その辺の石で地面にスコアを書いて対応していました。
地面の UX があまり高くなかったので、複数チームでも使える良い感じの Web スコアボードアプリがあるんじゃないかと検索してみると、意外と使いやすいものが見当たりません。
そこで、じゃあ自分たちで作ろう、という話になりました。
また、機能が少なく比較的簡単なアプリであることから、新人エンジニアの私が開発させていただくことになったという経緯です。
スコアボードアプリの機能・使い方
次に、このスコアボードアプリの機能と使い方について書いていきます。
機能は非常にシンプルで、チームの追加・削除と、得点の増減、後は全スコアのリセットができるだけです。
チーム名の編集機能も削りました。
チーム数は 2 〜 10 チームまで対応でき、スマホ横持ち時には以下のような表示になり、横スワイプで各得点を確認・操作できます。
特に、3 〜 4 チームで利用する場合は、スマホを横置きすることで、物理スコアボードと同じくらいの見やすさ・使いやすさを実現できるはずです。
とってもシンプルなので、パッと開いてパッと使用していただけると思います。
実装
次に、実装について書いていきます。
使用する state
アプリが非常にシンプルなので、今回使うステートは scoreList
のみです。
const [scoreList, setScoreList] = useState(DEFAULT_SCORE_LIST);
scoreList
は名前の通りただの得点の配列で、初期値には [0, 0]
を渡しています。
scoreList
に要素を追加・削除することでチーム数を増減し、各要素の値を更新することでそれぞれの得点を増減します。
スコアボードの表示処理
次に、スコアボードの表示処理について書いていきます。
<div className="flex w-full flex-col gap-8 sm:w-fit sm:min-w-full sm:flex-row sm:justify-center sm:gap-16 sm:px-8">
{/* チームコンポーネントをマップで表示 */}
{scoreList.map((score, index) => (
<Team
score={score}
index={index}
incrementScore={incrementScore}
decrementScore={decrementScore}
handleClickTeamDelete={handleClickTeamDelete}
key={index}
/>
))}
</div>
先ほどの scoreList
を .map()
で回して、各チームの Team
コンポーネントを生成しています。
Team
コンポーネントには、scoreList
内の要素の得点やインデックス番号、後述する関数群を渡します。
それらを元に Team
コンポーネント内で、各チームのスコア、チーム名、スコア増減ボタンおよびチーム削除ボタンを表示しています。
各スコアを増減する関数
ここからは、それぞれの関数について書いていきます。
まず、スコアを増減する関数について
ほぼ同じなので、インクリメントの方だけ説明します。
// 特定のスコアをインクリメントする関数
// インデックス番号を受け取って、対応するスコアをインクリメントする
const incrementScore = useCallback((index: number) => {
setScoreList((prevScoreList) => {
const newScoreList = prevScoreList.map((prevScore, prevScoreIndex) => {
// 一致するインデックス番号のスコアをインクリメント
const newScore = prevScoreIndex === index ? prevScore + 1 : prevScore;
// スコアを最大値で制限する
return newScore > MAX_SCORE ? MAX_SCORE : newScore;
});
return newScoreList;
});
}, []);
この incrementScore
は、インデックス番号を受け取り、scoreList
内の対応する要素のスコアを 1 点追加します。
スコアが MAX_SCORE
を超えるようなら MAX_SCORE
の値、越えなければインクリメントした値でそのスコアを更新します。
使用する際は、Team
コンポーネントにこの関数を渡し、下記のように、ボタンの onClick の関数定義の中で、インデックス番号を渡して実行するように書きます。
{/* プラスボタン */}
<PlusMinusButton plusOrMinus="plus" onClick={() => incrementScore(index)} />
チームを追加する関数
新しいチームを追加する関数は下記になります。
// スコア配列に新しいスコアを追加する関数
const addNewScore = useCallback(() => {
setScoreList((prevScoreList) => {
// チーム数が最大数未満なら新しいスコア(0)を追加する
const newScoreList = isMaxTeams(prevScoreList) ? prevScoreList : [...prevScoreList, 0];
return newScoreList;
});
}, []);
この addNewScore
は、実行されると、チーム数が最大数未満の場合に、scoreList
に新しい要素として「0」を追加します。
これにより、得点0のチームが新たに追加されます。
使用する際は下記のように、「チームを追加」ボタンの onClick に渡すだけです。
また、ボタンの disabled 属性にユーティリティ関数 isMaxTeams()
の実行結果を渡すことで、チーム数が最大になるとクリックできないようになります。
(後述しますが、scoreList
内の要素をスコアと呼んだりチームと呼んだりしてるのでちょっと混乱します)
{/* チームを追加ボタン */}
<Button
label={'チームを追加'}
onClick={addNewScore}
variant="secondary"
className="w-full"
disabled={isMaxTeams(scoreList)}
/>
チームを削除する関数
次に、チームを削除する関数についてです。
下記が削除ボタンのハンドラーです。
// チーム削除ボタンのハンドラ
// インデックス番号を受け取り、確認ダイアログで ok だったら、対応するスコアを削除する
const handleClickTeamDelete = useCallback(
async (index: number) => {
// 確認ダイアログを表示する
const isOk = await openConfirmDialog({
title: 'チームを削除しますか?',
content: 'チームを削除すると、スコアを元に戻すことはできません。',
okLabel: '削除',
ngLabel: 'キャンセル',
});
if (!isOk) {
return; // 何もしない
}
deleteScore(index);
},
[openConfirmDialog, deleteScore],
);
この関数にインデックス番号を渡して実行すると、自作の確認ダイアログが表示され、ダイアログの「削除」がクリックされた場合に、deleteScore()
が実行されます。
deleteScore()
は下記になります。
// 特定のスコアを削除する関数
// インデックス番号を受け取って、対応するスコアを削除する
const deleteScore = useCallback((index: number) => {
setScoreList((prevScoreList) => {
// 一致するインデックス番号以外のスコア配列を作る
const newScoreList = prevScoreList.filter((_, prevScoreIndex) => {
return prevScoreIndex !== index;
});
return newScoreList;
});
}, []);
deleteScore()
では、インデックス番号を受け取り、対応するスコアを scoreList
から取り除きます。
handleClickTeamDelete()
は、Team
コンポーネント内で、下記のように使用します。
{/* 削除ボタン(3チーム目以降表示する) */}
{isDeletableTeam(index) && (
<div className="absolute right-0 top-0 text-center sm:static sm:right-auto sm:top-auto sm:mt-6">
<button onClick={() => handleClickTeamDelete(index)}>
<CommonImage src="/icon/delete.svg" width={32} height={32} alt="delete team button" />
</button>
</div>
)}
2 チームまでは削除ボタンはいらないので、ユーティリティ関数 isDeletableTeam()
にインデックス番号を渡して判定し、出し分けします。
スコアをリセットする関数もこれとほぼ同じです。
以上で実装に関する説明は終わりです。
反省点
次に、反省点を書いていきます。
今回の開発の反省点としては、以下の点が挙げられます。
- 関数の命名がチグハグ
- 同じものに対して複数の呼び方をしている
関数の命名がチグハグ
ひとつ目の、「関数の命名がチグハグ」について
onClick に渡す関数の命名が「動詞 + 名詞」であったり、「handleClick + 名詞」であったりして、統一感がありません。
具体的には、以下のような感じです。
incrementScore
addNewScore
handleClickTeamDelete
handleClickReset
上記は、どれも onClick に渡す関数なのに、命名の形式が違うので、違和感を感じてしまいます。
多分コーディング中の自分は、確認ダイアログを表示させるものを区別するために「handleClick + 名詞」としていたのかなとは思いますが、こうやって後から見てみると微妙な気がします。
軽く調べてみると React の公式チュートリアル内の以下の説明が一番しっくりきたので、しばらくこれに沿ってコーディングしてみようと思います。
React では、イベントを表す props には onSomething という名前を使い、それらのイベントを処理するハンドラ関数の定義には handleSomething という名前を使うことが一般的です。
同じものに対して複数の呼び方をしている
二つ目の反省点について
同じものに対して複数の呼び方をしていて、読み手を(自分も)混乱させるコードになってしまいました。
具体的には、scoreList
配列内の要素のことを、「スコア」と呼んだり、「チーム」と呼んだりしています。
これはコメントアウト内でもそうですし、関数の命名もそのようになってしまっています。
コーディング中も、どっちの呼び方にするか迷いながら書いてましたが、後から見てみるとやっぱりひどいですね笑
今から考えると、scoreList
という state の命名が良くなかったです。
あくまでスコアはチームの持ち物ですから、teamList
としておいて、各要素をチーム呼び、その値をスコアと呼べば、もっと良くなる気がします。(確証はないですが)
当たり前ですが、おかしいと思ったら、一度立ち止まって、根本から考えなおさないといけないんだなと感じました。
おわりに
前回の Web タイマーに引き続き、Web スコアボードの開発でした。
コーディングでの反省点はありますが、純粋に使いやすい Web アプリになったと思います。
ぜひ、触ってみたください!