Home > Blockchain >  Processing action-creatos with dependencies in React via redux-toolkit
Processing action-creatos with dependencies in React via redux-toolkit

Time:10-19

In a React Redux frontend I'm currently experimenting with integrating HATEOAS into the overall process. The application initially starts without any knowledge other than the base URI the backend can be found at and the backend will add URIs to each resource the frontend is requesting.

The application itself currently is able to upload an archive file that contains files the backend should process based on some configuration the frontend is passing to the backend via multipart/form-data upload. Once the upload finished, the backend will create a new task for the upload and start processing the upload which results in some values being calculated that end up in a database where a controller in the backend is responsible for exposing various resources for certain files processed.

I currently use ReduxJS toolkit and slices combined with async thunks to manage and access the Redux store. The store itself has an own section dedicated for stuff returned by the backend API, i.e. the global links section, the page information and so forth and will store the result of each invokation with a slight remapping.

One of the challenges I faced here is that on initially requesting the component responsible for rendering all tasks, this component first needs to lookup the link for a predefined link-relation name in the backend API and on consecutive calls should reuse the available information. Here I came up with an action creator function like this:

type HalLinks = { [rel: string]: HalLink };

const requestLinks = async (uri: string, state: ApiState): Promise<[HalLinks, APILinkResponse | undefined]> => {
    let links: APILinkResponse | undefined;
    let rels: { [rel: string]: HalLink; };
    if (!state.links || Object.keys(state.links).length === 0) {
        console.debug(`Requesting links from ${uri}`);
        const linkLookup = await axios(uri, requestConfig);
        links = linkLookup.data;
        if (links) {
            rels = links._links;
        } else {
            throw new Error('Cannot resolve links in response');
        }
    } else {
        links = undefined;
        rels = state.links;
    }
    return [rels, links];
}

const lookupUriForRelName = (links: HalLinks, relName: string): HalLink | undefined => {
    if (links) {
        if (relName in links) {
            return links[relName];
        } else {
            return undefined;
        }
    } else {
        return undefined;
    }
}

const requestResource = async (link: HalLink, pageOptions?: PageOptions, filterOptions?: FilterOptions) => {
    let href: string;
    if (link.templated) {
        const templated: URITemplate = utpl(link.href);
        href = fillTemplateParameters(templated, pageOptions, filterOptions);

    } else {
        href = link.href;
    }
    console.debug(`Attempting to request URI ${href}`)

    const response = await axios.get(href, requestConfig);
    return response.data;
}

const lookup = async <T> (state: ApiState, uri: string, relName: string, pageOptions?: PageOptions, filterOptions?: FilterOptions): Promise<[T, APILinkResponse | undefined]> => {
    const [rels, links] = await requestLinks(uri, state);
    const link: HalLink | undefined = lookupUriForRelName(rels, relName);
    if (link) {
        const data = await requestResource(link, pageOptions, filterOptions);
        return [data, links];
    } else {
        throw new Error('No link relation for relation name '   relName   ' found');
    }
}

export const requestTasksAsync = createAsyncThunk<[APITasksResponse, APILinkResponse | undefined], { uri: string; pageOptions?: PageOptions; filterOptions?: FilterOptions; }>(
    'api/tasks',
    async ({ uri, pageOptions, filterOptions }: { uri: string, pageOptions?: PageOptions, filterOptions?: FilterOptions }, { getState }) => {
        const state: ApiState = getState() as ApiState;
        const TASK_REL = 'http://acme.com/rel/tasks'; // RFC 8288 compliant link relation extension according to section 2.1.2

        return await lookup<APITasksResponse>(state, uri, TASK_REL, pageOptions, filterOptions);
    }
);

In the code above I basically lookup up the links returned from the resource identified by the initial URI in case they are not present in the Redux store or in the state yet or are to old. In case we collected the links before this step is skipped and instead the information available within the state/store is reused (all happening in the requestLinks(...) function). Once the links are available we can finally request the resource that serves the tasks information we want to obtain.

The reducer for the action creator above looks like this:

export const apiSlice = createSlice({
    name: 'api',
    initialState,
    reducers: {
        ...
    },
    extraReducers: (builder) => {
        builder
            ...
            .addCase(requestTasksAsync.fulfilled, (state, action) => {
                const parts: [APITasksResponse, APILinkResponse | undefined] = action.payload;
                const tasks: APITasksResponse = parts[0];
                const linksResponse: APILinkResponse | undefined = parts[1];

                // update any link information received in preceeding calls ot
                // the performed actin
                if (linksResponse) {
                    processsLinks(linksResponse._links, state);
                }

                processsLinks(tasks._links, state);
                processPage(tasks.page, state);
                processTasks(tasks._embedded.tasks, state);

                state.status = StateTypes.SUCCESS;
            })
            ...
    }
});

where basically the two HTTP responses are taken and processed. In case we already retrieved those URIs before we don't have to lookup those again and as such the links response is undefined which we simply filter out with an if-statement.

The respective process functions are just helper functions to reduce the code in the reducer. I.e. the processTasks function just adds the task for the given taskId to the record present in the state:

const processTasks = (tasks: TaskType[], state: ApiState) => {
    for (let i = 0; i < tasks.length; i  ) {
        let task: TaskType = tasks[i];
        state.tasks[task.taskId] = task;
    }
}

while links are separated into global URIs and component ones based on whether a custom link relation is used or one specified by IANA (i.e. up, next, prev, ...)

const processsLinks = (links: { [rel: string]: HalLink }, state: ApiState) => {
    if (links) {
        Object.keys(links).forEach(rel => {
            if (rel.startsWith('http')) {
                if (!state.links[rel]) {
                    console.debug(`rel ${rel} not yet present in state.links. Adding it with URI ${links[rel]}`);
                    state.links[rel] = links[rel];
                } else {
                    console.debug(`rel ${rel} already present in state with value ${state.links[rel]}. Not going to add value ${links[rel]}`);
                }
            } else {
                if (state.current) {
                    console.debug(`Updateing rel ${rel} for current component to point to URI ${links[rel]}`);
                    state.current.links[rel] = links[rel];
                } else {
                    state.current = { links: { [rel]: links[rel] } };
                    console.debug(`Created new object for current component and assigned it the link for relation ${rel} with uri ${links[rel]}`);
                }
            }
        });
    }
}

Within the TaskOverview component the action is basically dispatched with the code below:

    const [pageOptions, setPageOptions] = useState<PageOptions | undefined>();
    const [filterOptions, setFilterOptions] = useState<TaskFilterOptions | undefined>()
    const state: StateTypes = useAppSelector(selectState);
    const current = useAppSelector(selectCurrent);
    const tasks: Record<string, TaskType> = useAppSelector(selectTasks);
    const dispatch = useAppDispatch();

    useEffect(() => {
        ...
        // request new tasks if we either have not tasks yet or options changed and we
        // are not currently loading them
        if (StateTypes.IDLE === state) {
            // lookup tasks
            dispatch(requestTasksAsync({uri: "http://localhost:8080/api", pageOptions: pageOptions, filterOptions: filterOptions}));
        }
        ...

    }, [dispatch, state, tasks, pageOptions, filterOptions])

The code above works. I'm able to lookup the URI based on the link relation and get the correct URI to retrieve the data exposed by the tasks resource and store those information into the Redux store. However, this all feels extremely clunky as I have to return two response objects from the action creation as I'm neither allowed to issue a dispatch from within a non-functional component nor alter the state within an action itself.

Ideally I'd love to dispatch actions in some way from within an async thunk and have a callback that informs me once the data is available in the store but I guess this is not possible. As I'm still fairly new to React/Redux I wonder if there is somehow a better approach available to trigger actions based on certain dependencies and in the absence of those load those dependencies before? I'm aware though that I could simply split the actions up into separated ones and then do the invocations within the respective component, though it feels like it will a) drag some of the state management logic the slice is responsible for into the respective component and b) duplicate some code.

CodePudding user response:

You can totally dispatch from within an asyncThunk.

export const requestTasksAsync = createAsyncThunk<[APITasksResponse, APILinkResponse | undefined], { uri: string; pageOptions?: PageOptions; filterOptions?: FilterOptions; }, { state: ApiState }>(
    'api/tasks',
    async ({ uri, pageOptions, filterOptions }, { getState, dispatch }) => {
        const state: ApiState = getState();
        const TASK_REL = 'http://acme.com/rel/tasks'; // RFC 8288 compliant link relation extension according to section 2.1.2

        dispatch(whatever())

        return await lookup<APITasksResponse>(state, uri, TASK_REL, pageOptions, filterOptions);
    }
);

Also, you don't need to repeat the arg type both in the generic and the payload generator function.

In your case, either use the generic (and then also move the ApiState type up into the generic), or skip the generic definition at the top and type everything inline.

  • Related