javascriptreactjsnode.jsxmlhttprequest

React Suspense within a Context


Those days I had to solve a problem for my react app, I have all my data to be displayed in JSONs, those are served by an express API using FS of node in order to read those JSONs and return them depending on the path you're passing in.

The real problem came when I tried to render my components, the components are useless without that JSON information, so I'll have to always wait till I get those.

My first idea was to call the API synchronously with XMLHttpRequest but I saw in the docs that it is deprecated. So I read about the new features of Suspense and Transitions but I do not understand it as well as placing them into my DataContext. I'll share with you all code I think is relevant in order to let you see where I reached:

// jsonLoader.js
// Here I try to get the data with XMLHttpRequest, I replaced to sync to async

export const loadJSON = async (path) => {
    const json = await readFile(path, 'application/json');
    return json ? JSON.parse(json) : undefined;
};

const readFile = async (path, mimeType) =>
    new Promise((resolve) => {
        const xmlHttp = new XMLHttpRequest();
        xmlHttp.open('GET', path, true);
        if (!!mimeType && xmlHttp.overrideMimeType) xmlHttp.overrideMimeType(mimeType);
        xmlHttp.send();
        if (xmlHttp.status == 200 && xmlHttp.readyState == 4) resolve(xmlHttp.responseText);
        else return resolve(undefined);
    });

Then I use that module in my DataContext:

// DataContext.js
// I'm trying to serve a "useQuery" called "getData" in order to fetch the API
let data = {};

const index = (obj, path) => path.split('.').reduce((o, i) => o[i], obj);
const setter = (obj, path, value) => {
    if (typeof path == 'string') return setter(obj, path.split('.'), value);
    else if (path.length == 1 && value !== undefined) return (obj[path[0]] = value);
    else if (path.length == 0) return obj;
    else return setter(obj[path[0]], path.slice(1), value);
};

const DataContextInstance = createContext({
    getData: async (...paths) => ({}),
    getTranslations: () => ({}),
});

export const DataContext = ({dataLoaded, children}) => {
    if (dataLoaded) data = dataLoaded;

    const {lang} = useContext(UserContext);
    const [prevLang, setPrevLang] = useState();

    const loadData = (path) => {
        if (Object.keys(data).length > 0) {
            const foundData = index(data, path);
            if (foundData?.then) throw foundData;
            if (foundData) return data;
        }
        const filePath = `/data/${path || `translations/${lang}`}.json`;
        const json = loadJSON(filePath).then((newData) => (data[path] = newData));
        data[path] = json;
    };

    const getData = (...paths) => {
        if (paths.every((p) => index(data, p))) return data;
        paths.forEach((p) => loadData(p));
        return data;
    };

    useEffect(() => {
        if (lang === prevLang && Object.keys(data).length > 0) return;
        if (Object.keys(data).length > 0) return;
        loadData();
        setPrevLang(lang);
    }, [lang, prevLang, setPrevLang, data]);

    const contextValue = useMemo(
        () => ({getData, getTranslations: () => getData()}),
        [data, lang]
    );

    return (
        <DataContextInstance.Provider value={contextValue}>
            <Suspense fallback={<span>Loading...</span>}>{children}</Suspense>
        </DataContextInstance.Provider>
    );
};

export const useDataContext = () => {
    const context = useContext(DataContextInstance);
    if (!context) throw new Error('Context must be used within a Provider');
    return context;
};

And then, I use that "getData" in my components in order to get the data needed for that one:

// NavBar.js
// Here I use my hook to get the DataContext context and get the "getData" func
function NavBar() {
    const {getData} = useDataContext();
    const {pages} = getData('menu').menu;
[...]

As you can see, I specify the json I want in every component in order to avoid loading all of them, so I have the "data" variable in my DataContext as a "cache", so if it is loaded I simply return it.

My problem is that I'm not able to make that work, it gets into a loop of calls and never getting on suspense (I think).

EDIT: I managed to capture an error log:

Warning: Cannot update a component (`DataContext`) while rendering a different component (`AppBase`). To locate the bad setState() call inside `AppBase`, follow the stack trace as described in https://reactjs.org/link/setstate-in-render

The JSX structure is:

<DataContext dataLoaded={data}>
    <AppBase data={data} statusCode={statusCode} />
</DataContext>

Solution

  • I've solved it by adding the following lines, after adding the data[path] = json (inserting a promise) I had to throw that promise in order to tell React it is loading a promise:

    [...]
    const filePath = `/data/${path || `translations/${lang}`}.json`;
    if (!path) path = lang;
    const json = loadJSON(filePath).then((newData) => (data[path] = newData));
    data[path] = json;
    if (data[path]?.then) throw data[path];
    [...]
    

    I followed the instructions from CSS Tricks.

    Thanks everyone for you help!