react-router icon indicating copy to clipboard operation
react-router copied to clipboard

[Feature]: useRequiredParams

Open tombuntus opened this issue 2 years ago • 4 comments

What is the new or updated feature that you are suggesting?

Export a useRequiredParams hook, which accepts a list of the route parameters required by a component, throws an error if they are not set, and returns parameter variables with type string.

Why should this feature be included?

The base useParams hook returns param variables with type string | undefined. This is the correct type definition, but commonly users of this hook are using it in a component they have no intention of rendering on a route without the route parameter in question. The string | undefined type causes (for valid reasons) friction in the dev experience. There is discussion of this here.

On one of my projects at work this was bothering me and I put together a useRequiredParams hook which checks if they are set, throws a useful error if not, and returns params with type string.

I think it would be helpful to include this alongside the base useParams hook (and just document that it will throw if a required param is unset).

I wrote up my own version here, but maybe a better implementation is possible. The point is that it throws a helpful error, and the hook name makes the intent explicit in contrast to useParams.

tombuntus avatar Apr 14 '22 09:04 tombuntus

Thanks, I implemented it slightly different so that a) only need to pass in the names as props, not as types, and b) not do the re-assignment since I don't mind if there are some other (optional) parameters:

import { useParams } from 'react-router-dom';

type RequiredParams<Key extends string = string> = {
	readonly [key in Key]: string;
};

export const useRequiredParams = <Params extends string>(
	requiredParamNames: Params[],
): RequiredParams<typeof requiredParamNames[number]> => {
	const routeParams = useParams();

	for (const paramName of requiredParamNames) {
		const parameter = routeParams[paramName];
		if (!parameter) {
			throw new Error(
				`This component should not be rendered on a route which does not have the ${paramName} parameter`,
			);
		}
	}
	return routeParams as RequiredParams<typeof requiredParamNames[number]>; // after looping through all required params, we can safely cast to the required type
};

mboettcher avatar May 12 '22 14:05 mboettcher

I wanted to share my take as well, since we heavily use static-path in my company.

const useSafeParams = <Pattern extends string>(
  path: Path<Pattern>
): Record<PathParamNames<Pattern>, string> => {
  const routerParams = useParams();

  return path.parts.reduce((currentParams, staticParam) => {
    if (staticParam.kind === "param") {
      const paramValue = routerParams[staticParam.paramName];
      if (paramValue === undefined) {
       /** Do something about it :) */
      } else {
        return {
          ...currentParams,
          [staticParam.paramName]: paramValue
        };
      }
    }
    return currentParams;
    /**
     * We are kinda lying to TypeScript here, but the thing is that if you pass a path with no parameters to `useParams`,
     * you will get a type error and therefore, this would never return an empty object (you can always bypass TypeScript, but should you?).
     */
  }, {} as Record<PathParamNames<Pattern>, string>);
};

Usage would be something like

const moviePath = path("/movies/:movieId/comments/:commentId");

/** Nicely typed and safe! */
const { movieId, commentId } = useSafeParams(moviePath);

/** Whoops, you get a type error! seasonId does not exist in type { movieId: string, commentId: string }  */
const { seasonId } = useSafeParams(moviePath);

reloadedhead avatar Oct 26 '22 07:10 reloadedhead

Just sharing my case:

Add type guard with predicate params is RequiredParams<T> which checks required param exists, thus can assure required params exists and any other params might be optional.

  • Just type/assure wrapper, so this way does not create new params object or manipulate value of params object from useParams.
type RouteParams = { [K in string]?: string };

type RequiredParams<Key extends string> = {
  readonly [key in Key]: string;
} & Partial<Record<Exclude<string, Key>, string>>;

const hasRequiredParams = <T extends string>(
  params: RouteParams,
  requiredParamNames: readonly T[]
): params is RequiredParams<T> =>
  requiredParamNames.every((paramName) => params[paramName] !== null && params[paramName] !== undefined);

const useRequiredParams = <T extends string>(requiredParamNames: readonly T[]): Readonly<RequiredParams<T>> => {
  const routeParams = useParams<RouteParams>();

  if (!hasRequiredParams(routeParams, requiredParamNames)) {
    throw new Error(
      [
        `This component should not be rendered on a route since parameter is missing.`,
        `- Required parameters: ${requiredParamNames.join(", ")}`,
        `- Provided parameters: ${JSON.stringify(routeParams)}`,
      ].join("\n")
    );
  }

  return routeParams;
};

// requiredParam: string
// someOtherParam: string | undefined -> defined by react-router
const { requiredParam, someOtherParam } = useRequiredParams(["requiredParam"]);

// managing requiredParamNames as array constant requires `as const` 
// since if typeof requiredParamNames is string[], return type of useRequiredParams will be `RequiredParams<string>`
// which is why typeof requiredParamNames param is `readonly T[]` in useRequiredParams 
const requiredParamNames = ["id", "name"] as const; // readonly ["id", "name"]
const { id, name } = useRequiredParams(requiredParamNames);

sni-J avatar Oct 26 '22 09:10 sni-J

Would love to see something like this. any plans for it?

ItaiPendler avatar Nov 20 '22 14:11 ItaiPendler

I'm going to convert this to a discussion so it can go through our new Open Development process. Please upvote the new Proposal if you'd like to see this considered!

brophdawg11 avatar Jan 09 '23 21:01 brophdawg11