はじめに
こんにちは。
アドカレ初参加、そしてQiitaに記事を書くということ自体初挑戦なフロントエンドエンジニアです。
僕が所属しているチームでは npm package のバージョンアップを3ヶ月に1回あたりのペースで定期的に行う運用をしています。
この度、自分が主導でモジュールアップデートを行う運びとなり数週間かけて実施していました。
そのアップデート対象の中に、リポジトリ内で別のパッケージに依存していてしばらく上げることが出来なかった
React 17.0.2 → React 18.2.0
Next10.2.3 → Next 13.0.2
へのアップデートも含まれていました。
この記事では、昨今フロントエンドにおける主要なJavaScriptライブラリ、フレームワークであるReact、Nextのメジャーアップデートを行う際に直面したエラーや事象について対応したことを備忘録としてまとめてみました。
同じような場面に直面した際にお役に立てれば幸いです。
前提として、
・TypeScriptを使用しているため型定義などの内容も含みますのでご了承ください。
・記載の内容は開発環境や設定ファイルに依存するものもあるため、全員が必ずしも発生する訳ではないです。
それでは本題へ!
children型の明記
コンポーネントの型定義には children が暗黙的に含まれていた React.FC を使用していました。React17.0.2では問題ありませんでしたが、React18以降では children を暗黙的にはサポートしなくなったため自身でpropsの型に明記する必要があります。
before
type Props = {
name: string;
type?: 'button' | 'submit' | 'reset';
handleClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export const ActionButton: React.FC<Props> = React.memo((props) => {
const hoge = //...
//...
<span className={styles.actionButton__text}>{children}</span>
// ...
);
});
after
type Props = {
name: string;
type?: 'button' | 'submit' | 'reset';
handleClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
+ children: React.ReactNode;
}
export const ActionButton: React.FC<Props> = React.memo((props) => {
const hoge = //...
//...
<span className={styles.actionButton__text}>{children}</span>
// ...
);
});
これに伴い React.FC から childrenの型定義を除いた React.VFC は非推奨になりました。
pagePropsの型定義変更に伴いAppPropsへのジェネリクス追加
Next 12.3.1 以降のバージョンからの差分です。
AppProps
の型定義が変わり、今までの記述のままだとエラーになってしまう事象に直面しました。
これまでのAppPropsの型定義と_app.tsxの実装部分は下記の通りです。
まずは以下3つがNext10.2.3 (12.3.0以前) での定義ファイルおよび実装していた.tsxファイルです。
/// <reference types="node" />
import React, { ErrorInfo } from 'react';
import { AppContextType, AppInitialProps, AppPropsType, NextWebVitalsMetric } from '../next-server/lib/utils';
import { Router } from '../client/router';
export { AppInitialProps };
export { NextWebVitalsMetric };
export declare type AppContext = AppContextType<Router>;
export declare type AppProps<P = {}> = AppPropsType<Router, P>;
/**
* `App` component is used for initialize of pages. It allows for overwriting and full control of the `page` initialization.
* This allows for keeping state between navigation, custom error handling, injecting additional data.
*/
declare function appGetInitialProps({ Component, ctx, }: AppContext): Promise<AppInitialProps>;
export default class App<P = {}, CP = {}, S = {}> extends React.Component<P & AppProps<CP>, S> {
static origGetInitialProps: typeof appGetInitialProps;
static getInitialProps: typeof appGetInitialProps;
componentDidCatch(error: Error, _errorInfo: ErrorInfo): void;
render(): JSX.Element;
}
export declare function Container(p: any): any;
export declare function createUrl(router: Router): {
readonly query: import("querystring").ParsedUrlQuery;
readonly pathname: string;
readonly asPath: string;
back: () => void;
push: (url: string, as?: string | undefined) => Promise<boolean>;
pushTo: (href: string, as?: string | undefined) => Promise<boolean>;
replace: (url: string, as?: string | undefined) => Promise<boolean>;
replaceTo: (href: string, as?: string | undefined) => Promise<boolean>;
};
//...
//...
export declare type AppInitialProps = {
pageProps: any;
};
export declare type AppPropsType<R extends NextRouter = NextRouter, P = {}> = AppInitialProps & {
Component: NextComponentType<NextPageContext, any, P>;
router: R;
__N_SSG?: boolean;
__N_SSP?: boolean;
};
//...
//...
import React from 'react';
import { AppProps } from 'next/app';
import { App as AppComponent } from '../components/templates/App';
const App = ({
Component,
pageProps,
router,
}: AppProps): React.ReactElement => {(
<AppComponent
Component={Component}
pageProps={pageProps}
router={router}
/>
)};
export default App;
そして下記2つが Next13.0.2 (Next12.3.1以降) にアップデートした時の型定義ファイルです。
import React from 'react';
import type { AppContextType, AppInitialProps, AppPropsType, NextWebVitalsMetric, AppType } from '../shared/lib/utils';
import type { Router } from '../client/router';
export { AppInitialProps, AppType };
export { NextWebVitalsMetric };
export declare type AppContext = AppContextType<Router>;
export declare type AppProps<P = any> = AppPropsType<Router, P>;
/**
* `App` component is used for initialize of pages. It allows for overwriting and full control of the `page` initialization.
* This allows for keeping state between navigation, custom error handling, injecting additional data.
*/
declare function appGetInitialProps({ Component, ctx, }: AppContext): Promise<AppInitialProps>;
export default class App<P = any, CP = {}, S = {}> extends React.Component<P & AppProps<CP>, S> {
static origGetInitialProps: typeof appGetInitialProps;
static getInitialProps: typeof appGetInitialProps;
render(): JSX.Element;
}
//...
//...
export declare type AppInitialProps<P = any> = {
pageProps: P;
};
export declare type AppPropsType<R extends NextRouter = NextRouter, P = {}> = AppInitialProps<P> & {
Component: NextComponentType<NextPageContext, any, any>;
router: R;
__N_SSG?: boolean;
__N_SSP?: boolean;
};
//...
//...
AppProps型の内部で、pagePropsに適用されている型が AppInitialProps
なのですが、
こちらが
type AppInitialProps = { pageProps: any; };
↓
type AppInitialProps<P = any> = { pageProps: P; };
と変更されており、
その前段のAppPropsTypeの型定義で
type AppPropsType<R extends NextRouter = NextRouter, P = {}> = AppInitialProps
↓
type AppPropsType<R extends NextRouter = NextRouter, P = {}> = AppInitialProps<P>
と変更されています。
pagePropsの型の初期値が any型 から {} 空オブジェクトの型に変わっています。
そのため、_app.tsx内でAppPropsにジェネリクスを渡して型エラーを解消しました。
before
import React from 'react';
import { AppProps } from 'next/app';
import { App as AppComponent } from '../components/templates/App';
const App = ({
Component,
pageProps,
router,
}: AppProps): React.ReactElement => {(
<AppComponent
Component={Component}
pageProps={pageProps}
router={router}
/>
)};
export default App;
after
import React from 'react';
import { AppProps } from 'next/app';
import { App as AppComponent } from '../components/templates/App';
+ import { RootState } from '../redux/modules/reducer';
const App = ({
Component,
pageProps,
router,
- }: AppProps): React.ReactElement => {(
+ }: AppProps<{ initialReduxState: Partial<RootState> }>): React.ReactElement => {(
<AppComponent
Component={Component}
pageProps={pageProps}
router={router}
/>
)};
export default App;
hydration error
React17まではwarningに留まっていましたが、React18以降ではerrorに格上げ されておりlocal環境立ち上げ時もエラーモーダルが表示されるなど、より強調されるようになっています。
エラー文はこちらです。
Error: Hydration failed because the initial UI does not match what was rendered on the server.
なぜhydration errorが発生するかの部分について細かくは記述しませんが、端的にいうと
サーバサイドで生成したDOMと、クライアントサイドで生成したDOMが一致していることを期待しているが、そこに差異が生じているとhydration error が発生してしまいます。
他にはhtml構造が正しくない場合などにも発生するようです。
今回僕が直面した事象もまさに上記の「htmlの差異」により発生していたため、build時の出力内容を変更するようにして解消しました。
React側のコードとbuild後のhtmlの内容は下記の通りです。
before
import React from 'react';
//...
//...
export const App: React.FC<Props> = ({
Component,
pageProps,
router,
}) => {
//...
return (
<Provider store={store}>
<div id="main">
<Component {...pageProps} />
</div>
<div id="loading"></div>
<div id="overlay"></div>
</Provider>
);
};
<!DOCTYPE html>
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width" />
...
...
</head>
<body>
<div id="__next">
<div id="main">
<div></div>
</div>
<div id="loading"></div>
<div id="overlay"></div>
</div>
<div id="page-script">
<script id="__NEXT_DATA__" type="application/json">{hogehgoe}</script>
</div>
</body>
</html>
after
import React, { useEffect, useState } from 'react';
//...
//...
export const App: React.FC<Props> = ({
Component,
pageProps,
router,
}) => {
//...
+ // hydration error対策
+ const [render, setRender] = useState(false);
+ useEffect(() => setRender(true), []);
return (
<Provider store={store}>
+ {render && (
+ <>
<div id="main">
<Component {...pageProps} />
</div>
<div id="loading"></div>
<div id="overlay"></div>
+ </>
+ )}
</Provider>
);
};
<!DOCTYPE html>
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width" />
...
...
</head>
<body>
<div id="__next">
- <div id="main">
- <div></div>
- </div>
- <div id="loading"></div>
- <div id="overlay"></div>
</div>
<div id="page-script">
<script id="__NEXT_DATA__" type="application/json">{hogehgoe}</script>
</div>
</body>
</html>
参考
https://nextjs.org/docs/messages/react-hydration-error
eslint-config-nextの追加 (lint設定の追記)
Next11から eslint-config-next
というパッケージがデフォルトでサポートされるようになりました。
新規でcreate-next-app を実行して環境構築を行なった場合は自動でインストールされます。
今回はNext10.3.2から手動でアップデートを行っており既存の環境には自動でインストールされないため、手動でパッケージをインストールします。
npm install --save-dev @next/eslint-plugin-next
eslintの設定ファイルにも既存のプラグインと併用するために、追記します。
module.exports = {
//...
extends: [
'airbnb',
'airbnb/hooks',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:prettier/recommended',
'plugin:@next/next/recommended' // こちらを追記
],
//...
また、僕のチームでは現在 next/image
のImage コンポーネントを使用していないため、上記lintでのwarningが発生するようになりました。
保守案件として対応予定のため、一時的にlintの対象から除外するように設定を記述しました。
module.exports = {
//...
rules: {
//...
'@next/next/no-img-element': 'off',
},
//...
静的インポート無効化の設定
Next.jsの next/image はv11.1.0から 「src prop の静的インポートサポート」 という機能が組み込まれており、
Imageコンポーネントのsrcプロパティに対して import した静的ファイルを渡すことが出来ます。
参考リンク:https://nextjs-ja-translation-docs.vercel.app/docs/api-reference/next/image
import Image from 'next/image';
import icon from './icon.png';
const hogeImage = (props) => {
return (
<Image
src={icon}
alt="hoge"
width={300}
height={300}
/>
)
}
基本的にはnext/imageを使用していない場合は既存の静的ファイルのimportと競合するため、上記の機能をオフにすることが出来ます。
next.config.jsに下記設定を追記することで、これまで通り通常のimgを使用した開発をすることが出来ました。
//...
images: {
disableStaticImages: true,
},
//...
まとめ
プロダクトで使用しているモジュールアップデートを行うということ自体学びが多かったのに、ReactやNextの最新バージョンでどんな機能をサポートしているか の理解が深まった気がします。
今回は既存のプロジェクトに新機能を盛り込むところまではせず、あくまで発生したエラーの対応に留まっています。
なので今後はSuspenseやServerSideRenderingなど新しい機能の知見も広げ、取り入れられるものは取り入れていきたいなと思いました。