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


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]);
}