最初に
今回、開発期間が半年ほどの新機能追加のタスクに参加して、新規画面の実装を行いました。
そこで table 要素を使って組んだレイアウトに無限ローディング機能 + ユーザが選択した要素を別テーブルで保持する機能の実装を行いました。
どのように実装したか紹介しようと思います。
仕様
まず実装前にデザイナーから Figma で作成された UI と共に以下のような要望がありました。
テーブルのレイアウトが 2 つ並んでいる
左のテーブルに要素の一覧がある。クリックするとチェックボックスがONになり、右のテーブルにXアイコンと共に表示される
追加された値を消すにはXアイコンを押すか、チェックボックスをオフにする
2 つのテーブルはまさに「運命共同体だ!!」という感じでした。
これだけだと、実装するのは難しかったため、デザイナーと話し合いながら細かい仕様を決めていきました。
- API から一覧の情報が返るので、取得した値を左のテーブルに表示
- 一度に表示される値は 10 件まで
- テーブルはデータ追加に伴い、一定の高さを超えるとデータをスクロールして閲覧する形にする
- 検索結果が 10 件以上の場合は下までスクロールされた際に追加で 10 件取得
- 1 万件までは 右のテーブルに追加されることを想定
- 右のテーブルには多くても 10 件ほどしか追加されない
- 取得した要素がクリックされたらチェックボックスを ON する
- チェックボックスが ON になったら、右のテーブルにクリックした値をXマークと共に追加
- チェックボックスを OFF にすると、右のテーブルから OFF にした値が消える
- Xマークをクリックすると、右のテーブルからクリックした要素が消え、左テーブルにあるチェックボックスを OFF にする
こんな感じにある程度、仕様を固めて実装に入りました。
実装
業務で書いたコードをそのまま載せられないので、ポケモン API を使って似たような機能を実装しました。
リポジトリ - テーブルレイアウト無限ローディング
こちらの実装したコードを使って、解説出来たらと思います。
git clone https://github.com/wimpykid719/react-component.git
npm install
npm start
データ構造
無限ローディングによって、取得したデータがどんどん配列へ追加されていきます。
そのため、全てのデータを 1 つの配列で保持してしまうと、操作してデータが追加されるたびにチェックボックスの状態更新の処理が重くなっていくと考えました。
なのでデータを 2 つの配列と連想配列(オブジェクト) の 3 つに分けて保持するようにしました。
イメージとしては
const pokemonNames = ['キー1', 'キー2', 'キー3', 'キー4'...]
const pokemonObj = {
キー1: {
name: 'ポケモン名前',
url: '詳細なURL',
checked: false,
},
キー2: {
name: 'ポケモン名前',
url: '詳細なURL',
checked: false,
},
キー3: {
name: 'ポケモン名前',
url: '詳細なURL',
checked: false,
},
キー4: {
name: 'ポケモン名前',
url: '詳細なURL',
checked: false,
},
}
const checkedPokemons = [
{name: 'ポケモン名前', url: '詳細なURL'},
{name: 'ポケモン名前', url: '詳細なURL'},
{name: 'ポケモン名前', url: '詳細なURL'},...
]
こんな感じにすることでデータ量が増えていっても、チェックボックスの状態を操作する処理はそこまで遅くならないようにしました。
テーブルレイアウト - 無限ローディング
左のテーブルレイアウト
<TableWrapper onScroll={onScroll}>
<Table ref={tableRef}>
<Thead>
<Tr className="TrTh">
<Th>ポケモン名</Th>
<Th>詳細URL</Th>
</Tr>
</Thead>
<Tbody>
{pokemonNames.length === 0 ? (
<Tr>
<Td colSpan={2}>
<NoPokemon>
{isError
? "ネットワークエラー、ポケモンを取得できません"
: "ポケモンが表示されます"}
</NoPokemon>
</Td>
</Tr>
) : (
pokemonNames.map((name) => (
<SerachedTr
className="TrTd"
key={name}
onClick={
pokemonObj[name]?.checked
? () => removePokemon(pokemonObj[name]?.name)
: () => addPokemon(pokemonObj[name])
}
>
<Td>
<Checkbox
checked={!!pokemonObj[name]?.checked}
label={pokemonObj[name]?.name || ""}
></Checkbox>
</Td>
<Td>{pokemonObj[name]?.url}</Td>
</SerachedTr>
))
)}
{!isLoading && (
<Tr>
<TdLoading colSpan={2}>
<LoaderWrapper>
<LoadingCircle />
</LoaderWrapper>
</TdLoading>
</Tr>
)}
</Tbody>
</Table>
</TableWrapper>
table 要素を div 要素(TableWrapper
)で囲っています。
理由としては table 要素は border-raidus
が指定出来ないのでラッパーを使ってテーブルの角丸を作ることにしました。
そして onScroll
をラッパーに設定しました。
テーブルがスクロールされる度に指定したメソッドが発火するようになっています。
const SCROLL_HEIGHT = 576;
...
const TableWrapper = styled.div`
overflow-x: auto;
max-width: 432px;
width: 100%;
border-radius: 4px;
border: solid 1px #ccc;
max-height: ${SCROLL_HEIGHT}px;
overflow-y: scroll;
margin-top: 20px;
@media screen and (max-width: 1279px) {
max-width: 600px;
}
@media screen and (max-width: 720px) {
min-width: 290px;
}
`;
TableWrapper
は最大の高さが 576px
になっていて中の table 要素がそれ以上になる時にスクロールできるようになっています。
※要素がテーブルヘッダ + 10 件の時に 576px
超えるようになっている。
const onScroll = async (e: React.UIEvent<HTMLDivElement>) => {
if (!tableRef.current) return;
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget;
if (
clientHeight + scrollTop !== scrollHeight ||
isLoading ||
!url ||
tableRef.current?.clientHeight < SCROLL_HEIGHT
)
return;
await execFetchPokemon();
};
スクロールが検知されると上記のメソッドが実行されます。
TableWrapper
の各要素の高さを取得して判定に利用しています。
- scrollHeight(紫の範囲): 隠れてスクロールできる要素全てを足した高さ
- clientHeight(青の範囲): 見えている要素の高さ。10 件のデータが入っている時は
max-height: 576px;
を指定しているので576
になる
- scrollTop(黄色の範囲): スクロールして上に隠れた部分の高さ。スクロールすると値が増えていく
なので clientHeight + scrollTop
を足して scrollHeight
と同じになる時に最下部まで要素がスクロールされたと判定することができます。この時、新たにデータを取得することで無限ローディングを実現しました。
判定の内容
- スクロールが最下部ではない
- ローディング中
- url が falsy な値
- table 要素の高さが
576px
未満
上記の際は無限ローディングが実行されないようにしました。
const execFetchPokemon = async () => {
setIsLoading(true);
await fetchPokemon(url);
setIsLoading(false);
};
...
const fetchPokemon = async (url: string | undefined) => {
try {
if (!url) return;
const response = await fetch(url);
const data: PokemonFetchedData = await response.json();
const nextUrl = data.next || undefined;
setUrl(nextUrl);
setPokemonObj((prePokemonObj) => {
const newPokemonObj = pokemonSelectCheckedObj(
data.results,
checkedPokemons
);
return { ...prePokemonObj, ...newPokemonObj };
});
setPokemonNames((prePokemonNames) => {
return [
...prePokemonNames,
...data.results.map((result) => result.name),
];
});
} catch {
setIsError(true);
}
};
取得処理が走ると state に保存された url を元に api にリクエストを投げます。受け取った値に次の url が含まれていたら state にセット。なければ undefined
をセットします。
pokemonSelectCheckedObj
で上で紹介したデータ構造にして、ポケモン名だけ配列も同時にステートにセットする。
ここまでが table 要素を使った無限ローディングになります。
ここから先は運命共同体のチェックボックスの解説になります。なので React で無限ローディングを実装したい人はこれで実装する事ができると思います。
チェックボックス
<SerachedTr
className="TrTd"
key={name}
onClick={
pokemonObj[name]?.checked
? () => removePokemon(pokemonObj[name]?.name)
: () => addPokemon(pokemonObj[name])
}
>
<Td>
<Checkbox
checked={!!pokemonObj[name]?.checked}
label={pokemonObj[name]?.name || ""}
></Checkbox>
</Td>
<Td>{pokemonObj[name]?.url}</Td>
</SerachedTr>
クリック出来る範囲を広くしたいので、実際にはクリック出来る要素はテーブルの行になります。
チェックのON / OFFによってクリック時に発火する 2 種類のメソッドを用意しています。
const addPokemon = (pokemon: Pokemon | undefined) => {
if (!pokemon) return;
const pokemonName = pokemon.name;
setPokemonObj((prePokemonObj) => {
return {
...prePokemonObj,
[pokemonName]: {
name: prePokemonObj[pokemonName]?.name || "",
url: prePokemonObj[pokemonName]?.url || "",
checked: true,
},
};
});
setCheckedPokemons((preCheckedPokemons) => {
const checkedPokemon = { name: pokemon.name, url: pokemon.url };
if (!preCheckedPokemons) {
return [checkedPokemon];
} else {
return [...preCheckedPokemons, checkedPokemon];
}
});
};
const removePokemon = (pokemonName: Pokemon["name"] | undefined) => {
if (!pokemonName) return;
setPokemonObj((prePokemonObj) => {
return {
...prePokemonObj,
[pokemonName]: {
name: prePokemonObj[pokemonName]?.name || "",
url: prePokemonObj[pokemonName]?.url || "",
checked: false,
},
};
});
setCheckedPokemons((preCheckedPokemons) => {
return preCheckedPokemons.filter(
(checkedPokemon) => checkedPokemon.name !== pokemonName
);
});
};
右のテーブルレイアウト
<TableWrapper>
<Table>
<Thead>
<Tr className="TrTh">
<Th>ポケモン名</Th>
<Th>詳細URL</Th>
</Tr>
</Thead>
<Tbody>
{!checkedPokemons || checkedPokemons.length === 0 ? (
<Tr>
<Td colSpan={2}>
<NoPokemon>選択中のポケモンが表示されます</NoPokemon>
</Td>
</Tr>
) : (
checkedPokemons.map((checkedPokemon) => (
<Tr className="TrTd" key={checkedPokemon.name}>
<PokemonNameTd>
<RemoveButton
onClick={() => removePokemon(checkedPokemon.name)}
/>
{checkedPokemon.name}
</PokemonNameTd>
<Td>{checkedPokemon.url}</Td>
</Tr>
))
)}
</Tbody>
</Table>
</TableWrapper>
右のテーブルには、左のテーブルでチェックボックスがONになった要素が表示されるようになっています。
RemoveButton
をクリックすると removePokemon
が実行されて左のテーブルのチェックボックスは OFF になり、右のテーブルから削除されるようになっています。
これで今回の仕様に沿った実装が出来ました!
最後に
無限ローディングをどうやって実装するんだろう...と最初は分からなかったのですが、なんとか実装出来てよかったです。
テーブルレイアウトに関しては苦手意識があって、はじめ既存のテーブルレイアウト(div で作られた)で実装しました。
既存のものにはレイアウト崩れがあって table 要素に書き直す事になった時は大変でした(ほとんどデザイナーの人に書いてもらった)。
その節はとても助かりました。ありがとうございます。
理解が足りていない部分もあったため、今回、自分の理解を深める目的も含めて会社のテックブログに解説記事を書いてみました。今後も工夫したコンポーネントなどを記事に出来たらと思います。
ここまで読んで頂きありがとうございました。
参考文献
Documentation - PokéAPI
Border style do not work with sticky position element