shinke1987.net
雑多な備忘録等のはず。
他のカテゴリ・タブ
目次
PR

React:useSWRの動作確認

2024-05-21 2025-08-18
カテゴリ: React

環境

React:v18.3.1

SWR:v2.2.5

isLoadingとisValidatingの動作確認では次のバージョンで実行した。

React:v19.1.0

SWR:v2.3.4

インストール

下記コマンドを実行してインストールする。

% npm install swr

テスト用簡易サーバを用意

HTTPリクエストを受け取ってから2秒後にレスポンスを返す簡易サーバを用意する。

Expressのインストール

% mkdir フォルダ名 && cd フォルダ名
% npm init
% npm install express

// インストールされたExpressのバージョンは4.19.2だった。

% vim app.js

app.jsの内容

const express = require('express')
const app = express()
const port = 3001

function sendResponse(res) {
  res.send('ApiServer : ' + Date());
}

const allowCrossDomain = function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
  res.header(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization, access_token'
  )

  // intercept OPTIONS method
  if ('OPTIONS' === req.method) {
    res.send(200)
  } else {
    next()
  }
}

app.use(allowCrossDomain)

app.get('/', (req, res) => {
  setTimeout(
    sendResponse,
    2000,
    res 
  );  

  // console.log('req = ', req);
  // console.log('res = ', res);
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

上記app.jsをコピペしたら、下記コマンドを実行してサーバを起動する。

% nvm use node
% node app.js

サーバ起動後、Webブラウザで http://localhost:3001 へアクセスし、
「ApiServer : (日時)」と2秒後に表示されれば良い。

App.tsxの内容

import React, {
  createContext, ReactNode, Suspense, useEffect, useLayoutEffect,
} from 'react';
import UseSwr from './UseSwr.tsx';
import Fetch from './Fetch.tsx';

  return (
    <>
      <Fetch />
      <hr />
      <Suspense fallback={<div>Now Loading...</div>}>
        <UseSwr />
      </Suspense>
    </>
  );
}

export default App;

Fetch.tsxの内容

import { ReactNode, useState } from 'react';

function Fetch(): ReactNode {
  const [showFetchData, setShowFetchData] = useState<Promise<string>|string>('初期値');
  const [startFetchData, setStartFetchData] = useState<string>('初期値');
  const [endFetchData, setEndFetchData] = useState<string>('初期値');
  const [countFetchData, setCountFetchData] = useState<number>(0);

  async function fetchData(): Promise<void> {
    setStartFetchData(Date());

    const response: Response = await fetch('http://localhost:3001');
    const promiseText: string = await response.text();
    setShowFetchData(promiseText);

    setEndFetchData(Date());
    setCountFetchData(countFetchData + 1);
  }

  return (
    <>
      <div>
        {`countFetchData = ${countFetchData}`}
        <br />
        {`startFetchData = ${startFetchData}`}
        <br />
        {`showFetchData = ${showFetchData}`}
        <br />
        {`endFetchData = ${endFetchData}`}
      </div>

      <div>
        <button
          type="button"
          onClick={fetchData}
        >
          fetchData
        </button>
      </div>
    </>
  );
}

export default Fetch;

UseSwr.tsxの内容

import { ReactNode, useState } from 'react';
import useSWR, { useSWRConfig } from 'swr';

function UseSwr(): ReactNode {
  // swrData関数用。
  const [startSwrData, setStartSwrData] = useState<string>('初期値');
  const [endSwrData, setEndSwrData] = useState<string>('初期値');
  const [countSwrData, setCountSwrData] = useState<number>(0);
  const [aboutCache, setAboutCache] = useState<ReactNode>(null);

  // キャッシュボタンのイベントハンドラで使用。
  const config = useSWRConfig();

  // useSWRフックで使用されるfetcher。
  async function fetcher(url: string): Promise<string> {
    setStartSwrData(Date());

    const promiseFetch: Response = await fetch(url);

    setEndSwrData(Date());
    setCountSwrData(countSwrData + 1);

    return promiseFetch.text();
  }

  // useSWRフックが成功した後に実行される関数。
  function swrOnSuccess(): void {
    console.log('swrOnSuccess');
    setCountSwrData(countSwrData + 1);
  }

  const { data, mutate } = useSWR<string>(
    'http://localhost:3001',
    fetcher,
    {
      suspense: true, // Suspenseコンポーネント用。20240521_非推奨となっている。
      onSuccess: swrOnSuccess, // 成功した後に実行される関数。
      revalidateIfStale: true, // 古いデータがあっても更新する。
      revalidateOnFocus: false, // フォーカスされても更新しない。
      revalidateOnReconnect: false, // ネットワーク接続復帰時に更新しない。
    },
  );

  const getAboutCache = () => {
    let component: ReactNode = '';

    config.cache.entries().forEach(([key, value]) => {
      component += `<div><div>key = ${key}</div><div>value.data = ${value.data}</div></div><br/>`;
    });
    component += `<div>config.cache.size = ${config.cache.size}</div>`;

    setAboutCache(component);
  };

  const handleRefresh = () => {
    // バウンドミューテートを利用。
    // Suspenseのfallbackは表示されない。
    mutate();
  };

  return (
    <>
      <div>
        {`countSwrData = ${countSwrData}`}
        <br />
        {`startSwrData = ${startSwrData}`}
        <br />
        {`showSwrData = ${data}`}
        <br />
        {`endSwrData = ${endSwrData}`}
      </div>

      <div>
        <button
          type="button"
          onClick={getAboutCache}
        >
          キャッシュ
        </button>
      </div>

      {aboutCache
        && (
          <>
            <br />
            <div dangerouslySetInnerHTML={{ __html: aboutCache }} />
          </>
        )}

      <br />
      <button
        type="button"
        onClick={handleRefresh}
      >
        更新
      </button>
    </>
  );
}

export default UseSwr;

結果

useSWRを利用したコンポーネントの作成例

import { ReactNode } from 'react';
import useSWR from 'swr';

async function fetcher(tempUrl: string): Promise<string|null> {
  const response = await fetch(tempUrl);
  return response.text();
}

/**
 * useSWRを利用したコンポーネントを作成する場合の例。
 * @constructor
 */

function UseSwrComponent(): ReactNode {
  const url: string = 'http://localhost:3001';


  const {
    data, error, isLoading, isValidating, mutate,
  } = useSWR(
    url,
    fetcher,
  );


  if (error) return <div>error</div>;
  if (isLoading) return <div>loading</div>;
  if (isValidating) return <div>validating</div>;


  function handleBtnMutate() {
    mutate();
  }


  return (
    <div>
      <div>
        {data}
      </div>
      <div>
        <button
          type="button"
          onClick={handleBtnMutate}
        >
          mutate
        </button>
      </div>
    </div>
  );
}

export default UseSwrComponent;

コンポーネントの一部にuseSWRを利用したデータを表示する例(State不使用)

import { ReactNode } from 'react';
import useSWR from 'swr';

async function fetcher(tempUrl: string): Promise<string|null> {
  const response = await fetch(tempUrl);
  return response.text();
}

/**
 * コンポーネントの一部分にuseSWRを利用したデータを表示する場合の例。
 * Stateを使わないやり方。
 * @constructor
 */

function ContainUseSwrData(): ReactNode {
  const url: string = 'http://localhost:3001';
  let contents: string = 'loading or validating';


  const {
    data, error, isLoading, isValidating, mutate,
  } = useSWR(
    url,
    fetcher,
  );


  // letで定義された変数でなく、useStateで作成されたset関数を利用するとTooManyReRenderのエラーとなる。
  if (error) contents = 'error';
  if (!isLoading && !isValidating) contents = String(data);


  function handleBtnMutate() {
    mutate();
  }


  return (
    <div>
      <div>
        {contents}
      </div>
      <div>
        <button
          type="button"
          onClick={handleBtnMutate}
        >
          mutate
        </button>
      </div>
    </div>
  );
}

export default ContainUseSwrData;

コンポーネントの一部にuseSWRを利用したデータを表示する例(State使用)

import { ReactNode, useEffect, useState } from 'react';
import useSWR from 'swr';

async function fetcher(tempUrl: string): Promise<string> {
  const response = await fetch(tempUrl);
  return response.text();
}

/**
 * コンポーネントの一部分にuseSWRを利用したデータを表示する場合の例。
 * Stateを利用するやり方。
 * @constructor
 */
function ContainUseSwrData2(): ReactNode {
  const url: string = 'http://localhost:3001';
  const [contents, setContents] = useState<string>('');
  let tempContents = 'loading or validating';


  const {
    data, error, isLoading, isValidating, mutate,
  } = useSWR(
    url,
    fetcher,
  );


  if (error) tempContents = 'error';
  if (!isLoading && !isValidating) tempContents = String(data);


  useEffect(() => {
    setContents(tempContents);
  }, [tempContents]);


  function handleBtnMutate() {
    mutate();
  }


  return (
    <div>
      <div>
        {contents}
      </div>
      <div>
        <button
          type="button"
          onClick={handleBtnMutate}
        >
          mutate
        </button>
      </div>
    </div>
  );
}

export default ContainUseSwrData2;

isLoadingとisValidatingの動作確認

概要

上記のテスト用簡易サーバを起動し、下記Default.tsxコンポーネントをWebブラウザで表示し、mutateボタンを実行する。

Default.tsxの内容

import { type ReactNode, useLayoutEffect } from 'react'
import useSWR from 'swr'

function Default(): ReactNode {


  const {data, isLoading, isValidating, error, mutate} = useSWR<string>(
    'http://localhost:3001',
    async (url: string): Promise<string> => {
      const response = await fetch(url);
      return response.text();
    }
  );


  console.log('=======================================================');
  console.log(`data = ${data}`);
  console.log(`isLoading = ${isLoading}`);
  console.log(`isValidating = ${isValidating}`);
  console.log(`error = ${error}`);
  console.log('=======================================================');


  useLayoutEffect(() => {
    console.log('初期描画');
  }, []);


  const clickBtnMutate = () => {
    console.log('mutateボタンがクリックされました');
    mutate();
  }


  return (
    <>
      Default.tsx
      <br />
      <button type="button" onClick={clickBtnMutate}>
        mutate
      </button>
    </>
  );
}

export default Default;

結果(コンソールの表示内容)

=======================================================
data = undefined
isLoading = true
isValidating = true
error = undefined
=======================================================
初期描画
=======================================================
data = ApiServer : Fri Jul 18 2025 15:55:38 GMT+0900 (日本標準時) 
isLoading = false
isValidating = false
error = undefined
=======================================================
mutateボタンがクリックされました
=======================================================
data = ApiServer : Fri Jul 18 2025 15:55:38 GMT+0900 (日本標準時) 
isLoading = false
isValidating = true
error = undefined
=======================================================
=======================================================
data = ApiServer : Fri Jul 18 2025 15:55:44 GMT+0900 (日本標準時) 
isLoading = false
isValidating = false
error = undefined
=======================================================

mutateにdataパラメータを渡した時の動作確認

概要

上記のテスト用簡易サーバを起動し、下記Default.tsxコンポーネントをWebブラウザで表示し、mutateボタンを実行する。

Default.tsxの内容

import { type ReactNode, useLayoutEffect } from 'react'
import useSWR from 'swr'

function Default(): ReactNode {


  const {data, isLoading, isValidating, error, mutate} = useSWR<string>(
    'http://localhost:3001',
    async (url: string): Promise<string> => {
      const response = await fetch(url);
      return response.text();
    }
  );


  console.log('=======================================================');
  console.log(`data = ${data}`);
  console.log(`isLoading = ${isLoading}`);
  console.log(`isValidating = ${isValidating}`);
  console.log(`error = ${error}`);
  console.log('=======================================================');


  useLayoutEffect(() => {
    console.log('初期描画');
  }, []);


  const clickBtnMutateWithOutData = () => {
    console.log('mutate(without data)ボタンがクリックされました');
    mutate();
  }


  const clickBtnMutateWithData = () => {
    console.log('mutate(with data)ボタンがクリックされました');
    mutate('abc');
  }


  return (
    <>
      Default.tsx
      <br />

      <button type="button" onClick={clickBtnMutateWithOutData}>
        mutate(without data)
      </button>
      <br />

      <button type="button" onClick={clickBtnMutateWithData}>
        mutate(with data)
      </button>
    </>
  );
}

export default Default;

結果(コンソールの表示内容)

=======================================================
data = undefined
isLoading = true
isValidating = true
error = undefined
=======================================================
初期描画
=======================================================
data = ApiServer : Fri Jul 18 2025 17:02:07 GMT+0900 (日本標準時) 
isLoading = false
isValidating = false
error = undefined
=======================================================
mutate(without data)ボタンがクリックされました
=======================================================
data = ApiServer : Fri Jul 18 2025 17:02:07 GMT+0900 (日本標準時) 
isLoading = false
isValidating = true
error = undefined
=======================================================
=======================================================
data = ApiServer : Fri Jul 18 2025 17:02:12 GMT+0900 (日本標準時) 
isLoading = false
isValidating = false
error = undefined
=======================================================
mutate(with data)ボタンがクリックされました
=======================================================
data = abc 
isLoading = false
isValidating = true
error = undefined
=======================================================
=======================================================
data = ApiServer : Fri Jul 18 2025 17:02:17 GMT+0900 (日本標準時) 
isLoading = false
isValidating = false
error = undefined
=======================================================

mutateの取得結果が前回と同一の時に描画するかの確認

概要

Expressから返される内容を同一のものに変更するために次の変更を行う。

テスト用簡易サーバのapp.jsの6行目を次のように変更する。

変更前:res.send(‘ApiServer : ‘ + Date());

変更後:res.send(‘ApiServer’);

描画されるかどうかはuseLayoutEffectフックを利用して確認する。

変更済みのテスト用簡易サーバを起動し、下記Draw.tsxコンポーネントをWebブラウザで表示し、mutateボタンを実行する。

Draw.tsxの内容

import { type ReactNode, useLayoutEffect } from 'react'
import useSWR from 'swr'

function Draw(): ReactNode {

  const {data, isLoading, isValidating, error, mutate} = useSWR<string>(
    'http://localhost:3001',
    async (url: string): Promise<string> => {
      const response = await fetch(url);
      return response.text();
    }
  );


  console.log('=======================================================');
  console.log(`data = ${data}`);
  console.log(`isLoading = ${isLoading}`);
  console.log(`isValidating = ${isValidating}`);
  console.log(`error = ${error}`);
  console.log('=======================================================');


  useLayoutEffect(() => {
    console.log('初期描画');
  }, []);


  useLayoutEffect(() => {
    console.log('描画');
  });


  const clickBtnMutate = () => {
    mutate();
  }

  return (
    <>
      Draw.tsx
      <br />

      <button type="button" onClick={clickBtnMutate}>
        mutate
      </button>
      <br />

      data = {data}
    </>
  );
}

export default Draw;

結果(コンソールの表示内容)

=======================================================
data = undefined 
isLoading = true
isValidating = true
error = undefined 
=======================================================
初期描画
描画
=======================================================
data = ApiServer 
isLoading = false
isValidating = false
error = undefined 
=======================================================
描画
=======================================================
data = ApiServer 
isLoading = false
isValidating = true
error = undefined 
=======================================================
描画
=======================================================
data = ApiServer 
isLoading = false
isValidating = false
error = undefined 
=======================================================
描画

キャッシュのdataを削除の動作確認

概要

キャッシュの情報を表示し、削除する動作確認。

キャッシュの表示方法は本Webページ上部の方法と少し違う。

キャッシュのdataを削除するだけで、キーは消えない。

テスト用簡易サーバを起動し、下記Cache.tsxコンポーネントをWebブラウザで表示し、mutateボタンを押下し、キャッシュボタンを削除ボタンを押下する。

Cache.tsxの内容

import type { ReactNode } from 'react'
import useSWR, { mutate, useSWRConfig } from 'swr'

function Cache(): ReactNode {

  const url: string = 'http://localhost:3001';
  const {cache} = useSWRConfig();

  const {data} = useSWR<string>(
    url,
    async (url: string): Promise<string> => {
      const response = await fetch(url);
      return response.text();
    }
  );


  console.log('=================================');
  console.log('キャッシュ情報');
  for (const key of cache.keys()) {
    console.log(`cache key = ${key}`);
    console.log(`cache data = ${cache.get(key)?.data}`);
  }
  console.log('=================================');


  const deleteCache = () => {
    console.log('キャッシュを削除ボタンが押下されました');
    mutate(url, undefined, {revalidate: false});
  }


  const refresh = () => {
    console.log('mutateボタンが押下されました');
    mutate(url);
  }


  return (
    <>
      <button type={'button'} onClick={refresh}>
        mutate
      </button>
      <br />

      <button type={'button'} onClick={deleteCache}>
        キャッシュを削除
      </button>
      <br />

      data = {data}
    </>
  );
}

export default Cache;

結果(コンソールの表示内容)

=================================
キャッシュ情報 
=================================
=================================
キャッシュ情報 
cache key = http://localhost:3001
cache data = ApiServer : Fri Jul 18 2025 23:10:39 GMT+0900 (日本標準時) 
=================================
mutateボタンが押下されました 
=================================
キャッシュ情報 
cache key = http://localhost:3001
cache data = ApiServer : Fri Jul 18 2025 23:10:44 GMT+0900 (日本標準時) 
=================================
キャッシュを削除ボタンが押下されました 
=================================
キャッシュ情報 
cache key = http://localhost:3001
cache data = undefined
=================================

keepPreviousDataの動作確認

概要

keepPreviousDataオプションが有効な状態と、無効な状態を比較する。

useSWRのキーを変更する場合に役立つこともあるはず。

テスト用簡易サーバを起動し、下記KeepPreviousData.tsxコンポーネントをWebブラウザで表示し、キーを変更ボタンを押下する。

KeepPreviousData.tsxの内容

import { type ReactNode, useState } from 'react'
import useSWR from 'swr'

function KeepPreviousData(): ReactNode {

  const url: string = 'http://localhost:3001';
  const [swrKey, setSwrKey] = useState<number>(0);

  const {data} = useSWR<string>(
    swrKey.toString(),
    async (): Promise<string> => {
      const response = await fetch(url);
      return response.text();
    },
    {
      keepPreviousData: true,
    }
  );

  
  console.log(`data = ${data}`);


  const clickBtn = () => {
    console.log('キーを変更ボタンが押下されました');
    setSwrKey(swrKey + 1);
  }


  return (
    <>
      <button type={'button'} onClick={clickBtn}>
        キーを変更
      </button>
      <br />
      
      data = {data}
    </>
  );
}

export default KeepPreviousData;

結果(コンソールの表示内容)

keepPreviousDataが無効な場合

data = undefined
data = ApiServer : Fri Jul 18 2025 23:55:49 GMT+0900 (日本標準時)
キーを変更ボタンが押下されました
data = undefined
data = ApiServer : Fri Jul 18 2025 23:55:54 GMT+0900 (日本標準時)

keepPreviousDataが有効な場合

data = undefined
data = ApiServer : Fri Jul 18 2025 23:57:37 GMT+0900 (日本標準時)
キーを変更ボタンが押下されました
data = ApiServer : Fri Jul 18 2025 23:57:37 GMT+0900 (日本標準時)
data = ApiServer : Fri Jul 18 2025 23:57:42 GMT+0900 (日本標準時)

populateCacheの動作確認

目的

既に取得したデータがある状態で、populateCacheオプションを設定してmutateする。

既存のデータとAPIから取得したデータが同じであれば何もしない。

既存のデータとAPIから取得したデータが違っていれば、
何らかのメッセージをユーザに表示し、
Yesが選択されたら更新し、Noが選択されたら更新しない、という処理を実現したい。

結果

実際にはモーダルやボタンをコンポーネント化するだろうけど、とりあえずできた。

Expressを利用した簡易APIサーバ

POSTでJSONを受け取る。

{testName: ‘useSWR’}というJSONを受け取ったら、時間を返す。

{testName: ‘mutate1’}というJSONを受け取ったら、mutate1の文字列を返す。

{testName: ‘mutate2’}というJSONを受け取ったら、mutate2の文字列を返す。

const express = require('express')
const app = express()
const port = 3001


const allowCrossDomain = function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
  res.header(
    'Access-Control-Allow-Headers',
    'Content-Type, Authorization, access_token'
  )

  // intercept OPTIONS method
  if ('OPTIONS' === req.method) {
    res.sendStatus(200)
  } else {
    next()
  }
}


app.use(allowCrossDomain)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())


app.post('/', (req, res) => {
  const testName = req.body.testName

  console.log(`testName = ${testName}`)

  if (testName === 'useSWR') {
    res.send('ApiServer: ' + Date())
  } else if (testName === 'mutate1') {
    res.send('mutate1')
  } else {
    res.send('mutate2')
  }
})


app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

コード

import { type ReactNode, useEffect, useState } from 'react'
import useSWR, { useSWRConfig } from 'swr'


function PopulateCache(): ReactNode {
  // APIのURL。
  const url: string = 'http://localhost:3001';

  // 初期描画時等に実行されるuseSWR。
  // サーバに{testName: 'useSWR'}というJSONが送信される。
  const {data} = useSWR<string>(
    url,
    async (url: string): Promise<string> => {
      const response = await fetch(
        url,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({testName: 'useSWR'}),
        });
      return response.text();
    },
  );

  // グローバルmutate取得。
  const {mutate} = useSWRConfig();

  // 更新可能なデータを格納するstateで、モーダル表示・非表示の視覚状態を表す。
  const [newData, setNewData] = useState<string|null>(null);


  // mutate(with PopulateCache)ボタンのイベントハンドラ。
  function mutateWithPopulateCache(num: number) {
    // サーバに{testName: 'mutate+数字'}というJSONが送信される。
    mutate(
      url,

      // もし第1引数が存在すれば、第1引数に現在のdataの値が入る。
      async (): Promise<string> => {
        const response = await fetch(
          url,
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({testName: `mutate${num}`}),
          });
        return response.text();
      },

      {
        // mutateの第2引数に指定した関数実行後、キャッシュの再検証のためにAPIと2重で通信するのを防ぐ。
        revalidate: false,

        // mutateの第2引数に指定した関数実行後、その通信結果とキャッシュを比べて更新する処理。
        // 第1引数にmutateの第2引数に指定した関数の戻り値が入り、第2引数にキャッシュデータが入る。
        populateCache: function (newData: string, oldData: string|undefined): string {
          console.log('===== populateCache =====');
          console.log(`newData = ${newData}`);
          console.log(`oldData = ${oldData}`);

          // 更新可能なデータがあった場合。
          if (newData !== oldData) {
            setNewData(newData);
          }

          return oldData ?? '';
        },
      },
    )
  }


  useEffect(() => {
    console.log(`data = ${data}`);
  }, [data]);


  useEffect(() => {
    if (newData !== null) {
      (document.getElementById('dialog') as HTMLDialogElement).showModal();
    }
  }, [newData]);


  // モーダルの「はい」ボタンのイベントハンドラ。
  function handleBtnYes(): void {
    mutate(url, newData, {revalidate: false});
    (document.getElementById('dialog') as HTMLDialogElement).close();
    setNewData(null);
  }


  // モーダルの「いいえ」ボタンのイベントハンドラ。
  function handleBtnNo(): void {
    (document.getElementById('dialog') as HTMLDialogElement).close();
    setNewData(null);
  }


  return (
    <>
      data = {data}
      <hr/>

      <button type={'button'} onClick={() => {
        mutateWithPopulateCache(1)
      }}>
        mutate1(with PopulateCache)
      </button>

      <br/>
      <br/>

      <button type={'button'} onClick={() => {
        mutateWithPopulateCache(2)
      }}>
        mutate2(with PopulateCache)
      </button>

      <dialog id="dialog">
        <form method="dialog">
          更新可能なデータがあります。<br/>
          更新してよろしいですか?<br/>

          <button
            type="button"
            id="btnYes"
            onClick={handleBtnYes}
          >
            はい
          </button>
          <br/>

          <button
            type="button"
            id="btnNo"
            onClick={handleBtnNo}
          >
            いいえ
          </button>
        </form>
      </dialog>
    </>
  );
}

export default PopulateCache;
同一カテゴリの記事