LoginSignup
20
24

More than 3 years have passed since last update.

React Suspense と Hooks を同時に使う方法について

Posted at

React Suspense とは

React には Suspense という機能があって、これは何かというと、あるコンポーネントの中で、Promise を throw する(!)と、throw した Promise が解決したときに、またレンダリングしに来てくれるというものです。その間は、親の Suspense の fallback で指定したコンポーネントがレンダリングを代理します。

const App: React.FC = () => {
  // UserProfile 内で Promise が throw されたら、
  // UserProfile の代わりに <div>Now Loading</div> が表示される
  return (
    <Suspense fallback={<div>Now Loading</div>}>
        <UserProfile id={id}/>
    </Suspense>
  );
};

UserProfile の中身は、例えばこんな感じです。

let userProfile: string | null = null;

const UserProfile: React.FC<{id: number}> = ({id}) => {
    if (userProfile == null) {
        throw loadUserProfile(id).then(value => userProfile = value);
    }
    return <div>Loaded: {userProfile}</div>;
}

export default UserProfile;

isLoading みたいなフラグを使えば良くない?」みたいな話はありますが、Suspense のよいところは、throw を使うことで、大域脱出ができる点です。isLoading のようなフラグを使う場合は、fallback するコンポーネントまで、バケツリレーなり、Context なりで状態を持ち運ばなければいけませんが、throw なら一発です。

Suspense と Hooks を同時に使う

さっきの例は、userProfile が単一の状態しか持てず、非常に悪い例でした。しかし、throw する Promise は毎回同じものにしたいので、メモ化したいですね。なので、useMemo を使って、 id 毎にメモ化するようにしましょう。

const UserProfile: React.FC<{ id: number }> = ({ id }) => {
    const [userProfile, setUserProfile] = useState<string | null>(null);
    const loadPromise = useMemo(() => loadUserProfile(id).then(setUserProfile), [id]);
    if (userProfile == null) {
        throw loadPromise;
    }
    return <div>Loaded: {userProfile}</div>;
}

export default UserProfile;

上手に書けましたね。イチコロです。偉大だ Hooks。ありがとう React 開発チーム。

と、言いたいところですが、上記のコードはなんと動きません!実際に動かしてみればわかりますが、永久にロードが終わりません。なぜでしょう?

throw された場合、Hooks の状態は破棄される

結論から言うと、↑の通りなのですが、コンポーネント内で throw が引き起こされた場合、Hooks の状態は破棄されます。useStateuseMemo も毎回リセットされます。なので、毎回新しい Promise を生成してしまって、ローディングが終わらなかったんですね。useEffect も発火されません。

それでも、Hooks を使いたい!

useMemo の内容が破棄されてしまうなら、自前でメモ化するようにすればよさそうですね。しかし、僕らは Hooks を使いたい!自前でメモ化などしたくない……!果たして、そんないい方法はあるのか……!

結論から言うと、コンポーネントを入れ子にすればいいです。


const UserProfile: React.FC<{ id: number }> = ({ id }) => {
    const loadPromise = useMemo(() => loadUserProfile(id), [id]);
    const getUserProfile = useGetPromiseValue(loadPromise);
    return <Inner getUserProfile={getUserProfile}></Inner>
};

function useGetPromiseValue<T>(promise: Promise<T>): () => T {
    return useMemo(() => {
        const setValuePromise = promise.then(v => { value = v }).catch(e => { error = e });
        let value: null | T = null;
        let error: any = null;
        return () => {
            if (error != null) {
                throw error;
            }
            if (value == null) {
                throw setValuePromise;
            }
            return value;
        }
    }, [promise]);
}

const Inner: React.FC<{ getUserProfile: () => string }> = ({ getUserProfile }) => {
    return <div>Loaded: {getUserProfile()}</div>;
};

useGetPromiseValue は Promise をラップした関数を返し、呼び出しで値を読み出します。Promise が未解決の場合は、throw をします。重要なのは、この関数が、入れ子にしたコンポーネントの中で呼ばれていることです。親のコンポーネントは既に解決されているので、useMemo の内容は保持されます。これで無事、Supense と Hooks を併用できましたね。

適当にラップして返せば、気分的にはただの値という世界観もある。

const UserProfile: React.FC<{ id: number }> = ({ id }) => {
    const loadPromise = useMemo(() => loadUserProfile(id), [id]);
    const userProfile = usePromiseElement(loadPromise);
    return <div>Loaded: {userProfile}</div>;
};

type WrapperProp<T> = { read: () => T }

function Wrapper<T>(props: WrapperProp<T>): React.ReactElement {
    return <Fragment>{props.read()}</Fragment>
}

function usePromiseElement<T>(promise: Promise<T>) {
    return useMemo(() => {
        const setValuePromise = promise.then(v => { value = v }).catch(e => { error = e });
        let value: null | T = null;
        let error: any = null;
        const read = () => {
            if (error != null) {
                throw error;
            }
            if (value == null) {
                throw setValuePromise;
            }
            return value;
        }
        return <Wrapper read={read}></Wrapper>
    }, [promise]);
}
20
24
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
20
24