0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactでTableのヘッダーをbodyスクロール時に常駐させつつ、列ヘッダーの常駐もさせる

Last updated at Posted at 2025-08-08

はじめに

テーブルを利用して何かしらの一覧画面を作成する際、Y軸スクロール時にヘッダーを常駐させたくなることがあるかと思います。
それも、table 内のスクロールではなく、body 内のスクロールに対して常駐させたくなることが。
何故かというと、table 内のスクロールでは画面全体のスクロールと table のスクロールでスクロールが二重になってしまうからです。

例えば、MUI の Table で stickyHeader を指定する事でもヘッダーの常駐は可能ですが、どうもTableContainerに対して常駐させる機能の様ですので、掲題の機能を完全には実現出来ません。(だぶん)

このような動作を実現しようとする場合は、table-header のみの table と table-body のみの table を準備してX軸スクロールを同期させるしかなさそうです。

おそらくは、これが一般的な方法なのではないかと思います。
なので、その方法をまとめておきたいと思います。

方針

ある程度、汎用的に使用できる Table.tsx を作成して機能の実現を図っていきたいと思います。
今回は React + MUI で作成しますが、ヘッダーの常駐は style で実現していくので、他の構成でも流用できるかと思います。

その他、以下の機能も盛り込んでいきます。

  • 行の左側に選択用のチェックボックスを表示する
  • テーブルヘッダーにソートアイコンを表示する

実装

型の定義

まず必要な型定義を行います。

types.ts
import type { ReactNode } from 'react'

// ソート情報の型定義
export type Order = 'asc' | 'desc'

// Tableに渡すデータのgenerics型定義の基底部
export type TDataBase = {
  id: number                  // コンポーネントkey用のID
}

// Tableに渡すヘッダー情報の型定義
export type HeaderItem<TData extends TDataBase> = {
  id: keyof TData             // コンポーネントkey用のID
  label: ReactNode
  width: string | number      // headerとbodyが分断されてしまう為、カラム幅を明示して固定にする
  sticky?:                    // 列カラム常駐用の定義。左右両方を想定する
    | {
        left: string | number
      }
    | {
        right: string | number
      }
}

hooks の定義

Ref を使用してテーブルヘッダーとテーブルボディのX軸スクロールを同期させる処理を hooks に隠蔽しておきます。

各 Ref が定義された Dom が存在しない内に useEffect が実行されると Ref の同期が上手くいかないので引数の isReady で制御します。

useSyncedHorizontalScroll.ts
import { useEffect, useRef } from 'react'

interface Args {
  isReady: boolean
}

export const useSyncedHorizontalScroll = ({ isReady }: Args) => {
  const scrollTargetRef = useRef<HTMLDivElement>(null)
  const syncedTargetRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!isReady) return

    const syncedTarget = syncedTargetRef.current
    const scrollTarget = scrollTargetRef.current

    if (!syncedTarget || !scrollTarget) return

    const onScroll = () => {
      syncedTarget.scrollLeft = scrollTarget.scrollLeft
    }

    scrollTarget.addEventListener('scroll', onScroll)
    return () => scrollTarget.removeEventListener('scroll', onScroll)
  }, [isReady])

  return {
    scrollTargetRef,
    syncedTargetRef,
  }
}

テーブルの定義

ルートコンポーネントとなるTableを作成します。
ここでは useSyncedHorizontalScroll を利用して Ref を取得し、TableBody と TableHeader に配布するだけです。

Table.tsx
import { CircularProgress, TablePagination } from '@mui/material'
import { useSyncedHorizontalScroll } from 'useSyncedHorizontalScroll'
import type { HeaderItem, Order, TDataBase } from 'types'
import { TableBody } from 'TableBody'
import { TableHeader } from 'TableHeader'

export type TableProps<TData extends TDataBase> = {
  isLoading?: boolean
  header: HeaderItem<TData>[]
  data: TData[]
  total: number
  selected?: TData[]
  setSelected?: (selected: TData[]) => void
  paginationParams: {
    page: number
    perPage: number
  }
  onPaginationChange: (params: {
    page: number
    perPage: number
  }) => void
  order?: Order
  setOrder?: (order: Order) => void
  orderBy?: keyof TData
  setOrderBy?: (key: keyof TData) => void
}

export const Table = <TData extends TDataBase>({
  isLoading = false,
  header,
  data,
  total,
  selected,
  setSelected,
  paginationParams,
  onPaginationChange,
  order,
  setOrder,
  orderBy,
  setOrderBy,
}: TableProps<TData>) => {
  const { scrollTargetRef, syncedTargetRef } = useSyncedHorizontalScroll({
    isReady: !isLoading && total > 0,
  })

  if (isLoading) {
    return (
      <div style={{ padding: 16, textAlign: 'center' }}>
        <CircularProgress />
      </div>
    )
  }

  if (total == 0) {
    return (
      <p>
        no record
      </p>
    )
  }

  return (
    <>

      <TableHeader
        ref={syncedTargetRef}
        header={header}
        data={data}
        selected={selected}
        setSelected={setSelected}
        order={order}
        orderBy={orderBy?.toString()}
        onRequestSort={(_event, property) => {
          const isAsc = orderBy === property && order === 'asc'
          setOrder?.(isAsc ? 'desc' : 'asc')
          setOrderBy?.(property)
        }}
      />

      <TableBody
        ref={scrollTargetRef}
        header={header}
        data={data}
        selected={selected}
        setSelected={setSelected}
      />

      <TablePagination
        component="div"
        count={total}
        page={paginationParams.page - 1}
        onPageChange={(_event, newPage) => {
          onPaginationChange({
            ...paginationParams,
            page: newPage + 1
          })
        }}
        rowsPerPage={paginationParams.perPage}
        onRowsPerPageChange={(event) => {
          onPaginationChange({
            ...paginationParams,
            perPage: parseInt(event.target.value, 10)
          })
        }}
      />
    </>
  )
}

続いて Table コンポーネントで使用する TableHeader と TableBody を作成します。

行選択用のチェックボックスは props.selected が指定された場合に限り表示させます。

ソート機能のソースは MUI のガイドそのままとなっています。
ソート機能は props.order, props.orderBy が指定された場合に限り有効化する想定です。
ソート処理は外部で実行され、 props.data に反映される想定です。

TableHeader.tsx
import {
  Table,
  TableContainer,
  TableHead,
  TableRow,
  TableCell,
  Checkbox,
  TableSortLabel,
  Box,
} from '@mui/material'
import { visuallyHidden } from '@mui/utils'
import React from 'react'
import type { HeaderItem, Order, TDataBase } from 'types'

export type TableHeaderProps<TData extends TDataBase> = {
  header: HeaderItem<TData>[]
  data: TData[]
  order?: Order
  orderBy?: string
  selected?: TData[]
  setSelected?: (selected: TData[]) => void
  onRequestSort: (
    event: React.MouseEvent<unknown>,
    property: keyof TData
  ) => void
}

export const TableHeader = React.forwardRef(function TableHeader<
  TData extends TDataBase
>(
  {
    header,
    data,
    order,
    orderBy,
    selected,
    setSelected,
    onRequestSort,
  }: TableHeaderProps<TData>,
  ref: React.ForwardedRef<HTMLDivElement>
) {
  return (
    <TableContainer
      ref={ref}
      sx={{
        position: 'sticky',
        top: '0px',
        overflowX: 'hidden',
        zIndex: 2,
      }}
    >
      <Table sx={{ whiteSpace: 'nowrap', width: 'auto' }}>
        <TableHead>
          <TableRow>
            {selected && (
              <TableCell
                padding="checkbox"
                onClick={(e) => {
                  const allSelected = data.every(row => selected.some(s => s.id === row.id))
                  setSelected?.(allSelected ? [] : data)
                  e.stopPropagation()
                }}
                sx={{
                  position: 'sticky',
                  minWidth: '48px',
                  maxWidth: '48px',
                  zIndex: 1,
                  left: 0,
                  backgroundColor: 'grey',
                }}
              >
                <Checkbox
                  color="primary"
                  checked={selected.length === data.length}
                />
              </TableCell>
            )}

            {header.map((headCell) => (
              <TableCell
                key={headCell.id.toString()}
                align={'left'}
                sx={{
                  minWidth: headCell.width,
                  maxWidth: headCell.width,
                  backgroundColor: 'grey',
                  ...(headCell.sticky
                    ? {
                        position: 'sticky',
                        zIndex: 1,
                        ...headCell.sticky,
                      }
                    : {}),
                }}
              >
                {order && orderBy ? (
                  <TableSortLabel
                    active={orderBy === headCell.id}
                    direction={orderBy === headCell.id ? order : 'asc'}
                    onClick={(event) => {
                      onRequestSort(event, headCell.id)
                    }}
                  >
                    {headCell.label}
                    {orderBy === headCell.id ? (
                      <Box component="span" sx={visuallyHidden}>
                        {order === 'desc'
                          ? 'sorted descending'
                          : 'sorted ascending'}
                      </Box>
                    ) : null}
                  </TableSortLabel>
                ) : (
                  headCell.label
                )}
              </TableCell>
            ))}
          </TableRow>
        </TableHead>
      </Table>
    </TableContainer>
  )
}) as <TData extends TDataBase>(
  props: TableHeaderProps<TData> & { ref?: React.Ref<HTMLDivElement> }
) => JSX.Element

TableBody.tsx
import {
  Table,
  TableContainer,
  TableRow,
  TableCell,
  TableBody as MuiTableBody,
  Checkbox,
} from '@mui/material'
import React from 'react'
import type { HeaderItem, TDataBase } from 'types'

export type TableBodyProps<TData extends TDataBase> = {
  header: HeaderItem<TData>[]
  data: TData[]
  selected?: TData[]
  setSelected?: (selected: TData[]) => void
}

export const TableBody = React.forwardRef(function TableBody<
  TData extends TDataBase
>(
  { header, data, selected, setSelected }: TableBodyProps<TData>,
  ref: React.ForwardedRef<HTMLDivElement>
) {
  return (
    <TableContainer ref={ref} sx={{ overflowX: 'scroll' }}>
      <Table sx={{ whiteSpace: 'nowrap', width: 'auto' }}>
        <MuiTableBody>
          {data.map((item) => {
            const isSelectedRow = selected?.some((e) => e.id === item.id) ?? false
            return (
              <React.Fragment key={item.id}>
                <TableRow
                  hover
                  sx={{
                    cursor: 'pointer',
                    backgroundColor: 'white',
                    '&:hover > td': {
                      backgroundColor: 'grey',
                    },
                  }}
                >
                  {selected && <TableCell
                    padding="checkbox"
                    onClick={(e) => {
                      const newSelected = isSelectedRow
                        ? selected.filter(({ id }) => id !== item.id)
                        : [...selected, item]

                      setSelected?.(newSelected)
                      e.stopPropagation()
                    }}
                    sx={{
                      position: 'sticky',
                      minWidth: '48px',
                      maxWidth: '48px',
                      zIndex: 1,
                      left: 0,
                      backgroundColor: 'white',
                    }}
                  >
                    <Checkbox color="primary" checked={isSelectedRow} />
                  </TableCell>}

                  {header.map((headCell) => (
                    <TableCell
                      key={headCell.id.toString()}
                      sx={{
                        minWidth: headCell.width,
                        maxWidth: headCell.width,
                        overflow: 'hidden',
                        textOverflow: 'ellipsis',
                        whiteSpace: 'nowrap',
                        backgroundColor: 'white',
                        ...(headCell.sticky
                          ? {
                              position: 'sticky',
                              zIndex: 1,
                              ...headCell.sticky,
                            }
                          : {}),
                      }}
                    >
                      {item[headCell.id as keyof TData] ?? '-'}
                    </TableCell>
                  ))}
                </TableRow>
              </React.Fragment>
            )
          })}
        </MuiTableBody>
      </Table>
    </TableContainer>
  )
}) as <TData extends TDataBase>(
  props: TableBodyProps<TData> & { ref?: React.Ref<HTMLDivElement> }
) => JSX.Element

※ forwardRef での Generics の使用について

ここでは React18 を使用しているため、スクロール機能の連携のために forwardRef を使用していますが、 forwardRef では Generics コンポーネントが作成できないため、今回はキャストで解決しています。

しかし、React19 では props から ref へアクセスできるようになっているので、このような型キャスト地獄とは無縁のようです。

使用例


export const TABLE_HEADERS: HeaderItem<Data>[] = [
  {
    id: 'id',
    label: 'ID',
    width: '80px',
    sticky: {
      left: '48px',
    },
  },
  {
    id: 'col1',
    label: 'Col1',
    width: '200px',
  },
  {
    id: 'col2',
    label: 'Col2',
    width: '200px',
  },
  {
    id: 'col3',
    label: 'Col3',
    width: '200px',
  },
  {
    id: 'col4',
    label: 'Col4',
    width: '200px',
  },
  {
    id: 'col5',
    label: 'Col5',
    width: '200px',
  },
  {
    id: 'col6',
    label: 'Col6',
    width: '200px',
  },
  {
    id: 'col7',
    label: 'Col7',
    width: '100px',
  },
  {
    id: 'col8',
    label: 'Col8',
    width: '200px',
  },
  {
    id: 'col9',
    label: 'Col9',
    width: '200px',
  },
  {
    id: 'col10',
    label: 'Col10',
    width: '200px',
  },
]

  const [paginationParams, setPaginationParams] = React.useState({
    page: 1,
    perPage: 20,
  })
  const [order, setOrder] = React.useState<Order>('asc')
  const [orderBy, setOrderBy] =
    React.useState<keyof Data>('id')
  const [selected, setSelected] = React.useState<Data[]>([])

  // TanStack Query のケース
  const { data, refetch, isLoading, isError } = useQuery({
    queryFn: async () => {
      return await axios.get<{
        data: Data[]
        total: number
      }>(...)
    },
  })

  return (
    <Table
      isLoading={isLoading}
      header={TABLE_HEADERS}
      data={data}
      total={data.total}
      selected={selected}
      setSelected={setSelected}
      paginationParams={paginationParams}
      onPaginationChange={setPaginationParams}
      order={order}
      setOrder={setOrder}
      orderBy={orderBy}
      setOrderBy={setOrderBy}
    />
  )
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?