Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RTKQ feature request: allow auto-binding endpoint parameters from store #4371

Open
michal-kurz opened this issue Apr 27, 2024 · 5 comments
Open

Comments

@michal-kurz
Copy link
Contributor

michal-kurz commented Apr 27, 2024

A vast majority of my queries and mutations contain at least one parameter which maps directly to information already present in redux store, and could be selected by a static selector. I tend to then make hook and thunk wrappers which bind the parameters for me - for example:

export const useQueryGetBotVersion = ({ version }: { version: string }) => {
  const botId = useSelector(openBotIdSelector)

  return botsApi.useGetBotVersionQuery(
    { id: botId!, version },
    { skip: !botId || !version }
  )
}

And often times analogous thunk+selector wrappers for the same endpoints when I need to work with .select() and .initiate() apis. This creates a lot of boilerplate mixed within more meaningful logic.

I would love to have access to an api which would allow me to provide store bindings for a subset of the parameters at endpoint level, so I don't have to manually bind those paremeters up to 3 times for each endpoint (useEndpoint hook + selector + thunk) - something like:

getBotVersion: builder.query<BotSchema, { id: BotId; version: string }>({
  query: ({ id, version }) => `bots/${id}/versions/${version}`,
  bindParamsFromStore: state => ({
    id: openBotIdSelector(state),
  })
})

It would help me improve my DX by eliminating all those single-purpose wrappers (injecting parameters to useXyzMutation result gets especially verbose and ugly for what it does).

Would you please consider this? Or am I missing some already existing solution to my problem?

Thank you 🙏

@michal-kurz
Copy link
Contributor Author

michal-kurz commented Apr 27, 2024

I assume that endpoint paremeter is not always an object though, so maybe the signature would have to be something like

function bindParamsFromStore<OuterParams>(getState: GetAppState, callSiteParams: OuterParams): EndpointParam | SkipToken

forcing the implementator to provide OuterParams type manually - hope this makes sense

This would also make the api more powerful (allowing selectors to react to provided params, and allow for auto-selecting nested properties), and would allow to skip when params are not yet loaded in store.

@EskiMojo14
Copy link
Collaborator

part of the trouble (at least Typescript wise) is that because your RootState type is partially derived from the API definition, using that RootState type anywhere can result in circular type issues

it would also complicate the types a lot, i suspect - having to keep track separately the "full endpoint arg" type, the "derived from state arg" type, and the "provided at call site arg" type

@michal-kurz
Copy link
Contributor Author

michal-kurz commented Apr 27, 2024

part of the trouble (at least Typescript wise) is that because your RootState type is partially derived from the API definition, using that RootState type anywhere can result in circular type issues

That is true, but some parts of API definition callbacks already do have access to RootState (for example baseQuery and queryFn), so I would hope this would not be a blocker :)

it would also complicate the types a lot, i suspect

Yeah, intuitively this sounds very painful to me, but I'm not familiar with the codebase, so I'm hoping for the best :) Also, based on my previous comment, only "full endpoint arg" and "provided at call site arg" would have to be tracked. This might be much easier, since there would be no particular relation to be codified between those two types.

@michal-kurz
Copy link
Contributor Author

michal-kurz commented Apr 27, 2024

Hmm, thinking about this a bit more, I might be able to create an outer wrapper layer which would do the arg bindings for me - something that would roughly look like this:

const createEndpointHelper = <CallSiteArgs, Endpoint extends EndpointDefinition>(
  endpoint: Endpoint,
  transformArgs: (state: RootState, callSiteArgs: CallSiteArgs) => ExtractArgs<Endpoint>
) => {
  return {
    initiate: (args: CallSiteArgs) => (dispatch, getState) => {
      dispatch(endpoint.utils.initiate(transformArgs(getState(), callSiteArgs)))
    },

    select: (args: CallSiteArgs) =>  (dispatch, getState) => {
      dispatch(endpoint.utils.select(transformArgs(getState(), callSiteArgs)))
    },

    useQuery: (args: CallSiteArgs, options) => {
      const args = useSelector(state => transformArgs(state, callSiteArgs))
      return endpoint.useQuery(args, options)
    },

    useMutation: options => {
      const [nativeTrigger, nativeResult] = endpoint.useMutation(options)

      return [
       // Not really sure how to access current state here, though.
       // I guess I might smuggle it outside of a thunk ...
       // const dispatch = useDispatch()
       // const getState = dispatch((dispatch, getState) => getState)
        (param: CallSiteArgs) => nativeTrigger(transformArgs(getState(), param)),
        nativeResult
      ] as const
    }
  }
}

I would just have to change my current code a bit to always trigger endpoints actions from my helpers ... thoughts? Would this complicate some other aspects of working with rtkq I am missing? Off the top of my head, it doesn't seem to be destructive to my current workflow in any way.

@michal-kurz
Copy link
Contributor Author

michal-kurz commented Apr 28, 2024

Ok, so I tried to implement my concept from previous comment, and it seems to works quite well so far. But I really struggled for many hours trying to get they types to work.

I didn't manage to get the inference of helper method return types to work - for exmaple for the return of helper.select() to inherit the types from the endpoint provided when calling createEndpointHelper(endpoint))

So I brute-forced typing all of the returns explicitly, and at this point they seems to be quite convincing (although probably incomplete, even for the implemented methods). But it resulted in some very ugly code imo (a lot of brute-force type assertions, and the readability-to-functionality ratio seems atrocious to me):

CODE HERE
import { useDispatch, useSelector } from 'react-redux'
import { ApiEndpointMutation, ApiEndpointQuery } from '@reduxjs/toolkit/dist/query/core/module'
import {
  MutationHooks,
  QueryHooks,
  UseMutation,
  UseMutationStateOptions,
  UseQuery,
  UseQueryStateOptions
} from '@reduxjs/toolkit/dist/query/react/buildHooks'
import { RootState } from '../../reducers'
import { FeedbotDispatch } from '../../store/types'
import { QueryResultSelectorResult } from '@reduxjs/toolkit/dist/query/core/buildSelectors'
import {
  MutationActionCreatorResult,
  QueryActionCreatorResult
} from '@reduxjs/toolkit/dist/query/core/buildInitiate'
import type { MutationSubState, RequestStatusFlags } from '@reduxjs/toolkit/src/query/core/apiState'

type AnyEndpoint = ApiEndpointQuery<any, any> | ApiEndpointMutation<any, any>

type EndpointArgs<Endpoint extends AnyEndpoint> = Parameters<Endpoint['initiate']>[0]

// I didn't get it to work without re-defining this type from scratch - likely a skill issue
type UseMutationResult<Endpoint extends AnyEndpoint> = MutationSubState<
  Endpoint extends ApiEndpointQuery<infer Definition, any> ? Definition : never
> &
  RequestStatusFlags & {
    originalArgs?: EndpointArgs<Endpoint>
    reset: () => void
  }

export const createEndpointHelper = {
  query: <CallSiteArgs, Endpoint extends ApiEndpointQuery<any, any> & QueryHooks<any>>(
    endpoint: Endpoint,
    skipCondition: (args: EndpointArgs<Endpoint>) => boolean,
    transformArgs: (state: RootState, callSiteArgs: CallSiteArgs) => EndpointArgs<Endpoint>
  ) => {
    type Definition = Endpoint extends ApiEndpointQuery<infer Definition, any> ? Definition : never

    return {
      initiate: (
        callSiteArgs: CallSiteArgs
      ): ThunkAction<QueryActionCreatorResult<Definition>, any, any, AnyAction> => {
        return async (dispatch: FeedbotDispatch, getState: () => RootState): any => {
          const args = transformArgs(getState(), callSiteArgs)
          if (skipCondition(args)) {
            return
          }

          return dispatch(endpoint.initiate(args))
        }
      },

      select: (callSiteArgs: CallSiteArgs) => {
        return (state: RootState) => {
          const args = transformArgs(state, callSiteArgs)
          return endpoint.select(args)(state as any) as QueryResultSelectorResult<Definition>
        }
      },

      useQuery: (callSiteArgs: CallSiteArgs, options?: UseQueryStateOptions<any, any>) => {
        const args = useSelector(state => transformArgs(state as any, callSiteArgs))
        const useQuery = endpoint.useQuery as UseQuery<Definition>
        // @ts-ignore
        return useQuery(args, { skip: skipCondition(args) })
      },

      useData: (callSiteArgs: CallSiteArgs, options?: UseQueryStateOptions<any, any>) => {
        const args = useSelector(state => transformArgs(state as any, callSiteArgs))
        const useQuery = endpoint.useQuery as UseQuery<Definition>
        // @ts-ignore
        return useQuery(args, { skip: skipCondition(args) })?.data
      }
    }
  },

  mutation: <CallSiteArgs, Endpoint extends ApiEndpointMutation<any, any> & MutationHooks<any>>(
    endpoint: Endpoint,
    skipCondition: (args: EndpointArgs<Endpoint>) => boolean,
    transformArgs: (state: RootState, callSiteArgs: CallSiteArgs) => EndpointArgs<Endpoint>
  ) => {
    type Definition = Endpoint extends ApiEndpointMutation<infer Definition, any>
      ? Definition
      : never

    return {
      initiate: (
        callSiteArgs: CallSiteArgs
      ): ThunkAction<MutationActionCreatorResult<Definition>, any, any, AnyAction> => {
        return async (dispatch: FeedbotDispatch, getState: () => RootState): any => {
          const args = transformArgs(getState(), callSiteArgs)
          if (skipCondition(args)) {
            return
          }

          return dispatch(endpoint.initiate(args))
        }
      },

      useMutation: (options?: UseMutationStateOptions<Definition, any>) => {
        const dispatch = useDispatch()
        const useMutation = endpoint.useMutation as UseMutation<Definition>
        const [nativeTrigger, nativeResult] = useMutation(options)

        return [
          (param: CallSiteArgs) => {
            // @ts-ignore
            const getState: () => RootState = dispatch((dispatch, getState) => getState)
            const args = transformArgs(getState(), param)
            nativeTrigger(args as any)
          },
          nativeResult as UseMutationResult<Endpoint>
        ] as const
      }
    }
  }
}

// skip utils
export const skipWhenSomeFalsy =
  <O extends Object>(...trackedParams: (keyof O)[]) =>
  (obj: O): boolean => {
    return trackedParams.some(param => !obj[param])
  }

export const neverSkip = () => false

// Args transformers
export const doNotInject =
  <T>() =>
  (state: RootState, value: T) =>
    value

And I define my endpoint helpers like this (quite like this syntax):

const getBotMetadataQueryEndpointHelper = createEndpointHelper.query(
  botsApi.endpoints.getBotsMetadata,
  neverSkip,
  doNotInject<void>()
)

const originalVersionQueryHelper = createEndpointHelper.query(
  botsApi.endpoints.getBotVersion,
  skipWhenSomeFalsy('id', 'version'),
  (state, callSiteArgs: void) => {
    return {
      id: openBotIdSelector(state)!,
      version: originalVersionStringSelector(state)!
    }
  }
)

Any ideas about how to improve the typings are very welcome!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants