/** * Mandu Router Hooks ๐Ÿช * React hooks for client-side routing */ import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from "react"; import { subscribe, getRouterState, navigate, submitAction, type RouteInfo, type NavigationState, type NavigateOptions, type ActionResult, } from "./router"; /** * ๋ผ์šฐํ„ฐ ์ƒํƒœ ์ „์ฒด ์ ‘๊ทผ * * @example * ```tsx * const { currentRoute, loaderData, navigation } = useRouterState(); * ``` */ export function useRouterState() { return useSyncExternalStore( subscribe, getRouterState, getRouterState // SSR์—์„œ๋„ ๋™์ผ ); } /** * ํ˜„์žฌ ๋ผ์šฐํŠธ ์ •๋ณด * * @example * ```tsx * const route = useRoute(); * console.log(route?.id, route?.params); * ``` */ export function useRoute(): RouteInfo | null { const state = useRouterState(); return state.currentRoute; } /** * URL ํŒŒ๋ผ๋ฏธํ„ฐ ์ ‘๊ทผ * * @example * ```tsx * // URL: /users/123 * const { id } = useParams<{ id: string }>(); * console.log(id); // "123" * ``` */ export function useParams = Record>(): T { const route = useRoute(); return (route?.params ?? {}) as T; } /** * ํ˜„์žฌ ๊ฒฝ๋กœ๋ช… * * @example * ```tsx * const pathname = usePathname(); * console.log(pathname); // "/users/123" * ``` */ export function usePathname(): string { const [pathname, setPathname] = useState(() => typeof window !== "undefined" ? window.location.pathname : "/" ); useEffect(() => { const handleChange = () => { setPathname(window.location.pathname); }; window.addEventListener("popstate", handleChange); // ๋ผ์šฐํ„ฐ ์ƒํƒœ ๋ณ€๊ฒฝ ๊ตฌ๋… const unsubscribe = subscribe(() => { setPathname(window.location.pathname); }); return () => { window.removeEventListener("popstate", handleChange); unsubscribe(); }; }, []); return pathname; } /** * ํ˜„์žฌ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ (์ฟผ๋ฆฌ ์ŠคํŠธ๋ง) * * @example * ```tsx * // URL: /search?q=hello&page=2 * const searchParams = useSearchParams(); * console.log(searchParams.get("q")); // "hello" * ``` */ export function useSearchParams(): URLSearchParams { const [searchParams, setSearchParams] = useState(() => typeof window !== "undefined" ? new URLSearchParams(window.location.search) : new URLSearchParams() ); useEffect(() => { const handleChange = () => { setSearchParams(new URLSearchParams(window.location.search)); }; window.addEventListener("popstate", handleChange); const unsubscribe = subscribe(() => { setSearchParams(new URLSearchParams(window.location.search)); }); return () => { window.removeEventListener("popstate", handleChange); unsubscribe(); }; }, []); return searchParams; } /** * Loader ๋ฐ์ดํ„ฐ ์ ‘๊ทผ * * @example * ```tsx * interface UserData { name: string; email: string; } * const data = useLoaderData(); * ``` */ export function useLoaderData(): T | undefined { const state = useRouterState(); return state.loaderData as T | undefined; } /** * ๋„ค๋น„๊ฒŒ์ด์…˜ ์ƒํƒœ (๋กœ๋”ฉ ์—ฌ๋ถ€) * * @example * ```tsx * const { state, location } = useNavigation(); * * if (state === "loading") { * return ; * } * ``` */ export function useNavigation(): NavigationState { const state = useRouterState(); return state.navigation; } /** * ํ”„๋กœ๊ทธ๋ž˜๋งคํ‹ฑ ๋„ค๋น„๊ฒŒ์ด์…˜ * * @example * ```tsx * const navigate = useNavigate(); * * const handleClick = () => { * navigate("/dashboard"); * }; * * const handleSubmit = () => { * navigate("/success", { replace: true }); * }; * ``` */ export function useNavigate(): (to: string, options?: NavigateOptions) => Promise { return useCallback((to: string, options?: NavigateOptions) => { return navigate(to, options); }, []); } /** * ๋ผ์šฐํ„ฐ ํ†ตํ•ฉ ํ›… (ํŽธ์˜์šฉ) * * @example * ```tsx * const { * pathname, * params, * searchParams, * navigate, * isNavigating * } = useRouter(); * ``` */ export function useRouter() { const pathname = usePathname(); const params = useParams(); const searchParams = useSearchParams(); const navigation = useNavigation(); const navigateFn = useNavigate(); return { /** ํ˜„์žฌ ๊ฒฝ๋กœ๋ช… */ pathname, /** URL ํŒŒ๋ผ๋ฏธํ„ฐ */ params, /** ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ (์ฟผ๋ฆฌ ์ŠคํŠธ๋ง) */ searchParams, /** ๋„ค๋น„๊ฒŒ์ด์…˜ ํ•จ์ˆ˜ */ navigate: navigateFn, /** ๋„ค๋น„๊ฒŒ์ด์…˜ ์ค‘ ์—ฌ๋ถ€ */ isNavigating: navigation.state === "loading", /** ๋„ค๋น„๊ฒŒ์ด์…˜ ์ƒํƒœ ์ƒ์„ธ */ navigation, }; } /** * ํŠน์ • ๊ฒฝ๋กœ์™€ ํ˜„์žฌ ๊ฒฝ๋กœ ์ผ์น˜ ์—ฌ๋ถ€ * * @example * ```tsx * const isActive = useMatch("/about"); * const isUsersPage = useMatch("/users/:id"); * ``` */ export function useMatch(pattern: string): boolean { const pathname = usePathname(); const regex = useMemo(() => { // ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํ”Œ๋ ˆ์ด์Šคํ™€๋”๋กœ ์น˜ํ™˜ โ†’ ๋‚˜๋จธ์ง€ ํŠน์ˆ˜๋ฌธ์ž ์ด์Šค์ผ€์ดํ”„ โ†’ ํ”Œ๋ ˆ์ด์Šคํ™€๋” ๋ณต์› const PLACEHOLDER = "\x00PARAM\x00"; const withPlaceholders = pattern.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, PLACEHOLDER); const escaped = withPlaceholders.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regexStr = escaped.replace( new RegExp(PLACEHOLDER.replace(/\x00/g, "\\x00"), "g"), "[^/]+" ); return new RegExp(`^${regexStr}$`); }, [pattern]); return regex.test(pathname); } /** * ๋’ค๋กœ ๊ฐ€๊ธฐ */ export function useGoBack(): () => void { return useCallback(() => { if (typeof window !== "undefined") { window.history.back(); } }, []); } /** * ์•ž์œผ๋กœ ๊ฐ€๊ธฐ */ export function useGoForward(): () => void { return useCallback(() => { if (typeof window !== "undefined") { window.history.forward(); } }, []); } /** * Action ๋ฐ์ดํ„ฐ ์ ‘๊ทผ * filling.action()์˜ ๊ฒฐ๊ณผ ๋ฐ์ดํ„ฐ * * @example * ```tsx * const actionData = useActionData<{ created: boolean }>(); * if (actionData?.created) { ... } * ``` */ export function useActionData(): T | undefined { const state = useRouterState(); return state.actionData as T | undefined; } /** * Action ์ œ์ถœ ํ•จ์ˆ˜ * * @example * ```tsx * const submit = useSubmit(); * const handleCreate = () => submit("/api/todos", { title: "New" }, "create"); * ``` */ export function useSubmit(): ( url: string, data: FormData | Record, actionName?: string, method?: string ) => Promise { return useCallback( (url: string, data: FormData | Record, actionName = "default", method = "POST") => { return submitAction(url, data, actionName, method); }, [] ); } /** * Mandu ํ†ตํ•ฉ ์ƒํƒœ ํ›… * ๋ผ์šฐํŠธ, ๋ฐ์ดํ„ฐ, ๋„ค๋น„๊ฒŒ์ด์…˜, ์•ก์…˜ ์ƒํƒœ๋ฅผ ํ•˜๋‚˜์˜ ๊ฐ์ฒด๋กœ ์ œ๊ณต * * @example * ```tsx * const { * url, params, routeId, * loaderData, actionData, * navigation, * navigate, submit, * } = useMandu(); * ``` */ export function useMandu() { const state = useRouterState(); const navigateFn = useNavigate(); const submitFn = useSubmit(); // URL์€ ๋ผ์šฐํ„ฐ ์ƒํƒœ๊ฐ€ ์‹ค์ œ๋กœ ๋ฐ”๋€” ๋•Œ๋งŒ ์žฌ์ƒ์„ฑํ•œ๋‹ค. // query-only navigation๋„ ๋ฐ˜์˜๋˜๋„๋ก ์ƒํƒœ ์ „์ฒด๋ฅผ ์˜์กด์„ฑ์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. const url = useMemo( () => typeof window !== "undefined" ? new URL(window.location.href) : null, [state] ); return { /** ํ˜„์žฌ URL (๋ธŒ๋ผ์šฐ์ €) */ url, /** URL ํŒŒ๋ผ๋ฏธํ„ฐ */ params: (state.currentRoute?.params ?? {}) as Record, /** ํ˜„์žฌ ๋ผ์šฐํŠธ ID */ routeId: state.currentRoute?.id ?? "", /** ๋ผ์šฐํŠธ ํŒจํ„ด */ pattern: state.currentRoute?.pattern ?? "", /** Loader ๋ฐ์ดํ„ฐ */ loaderData: state.loaderData, /** ๋งˆ์ง€๋ง‰ Action ๊ฒฐ๊ณผ */ actionData: state.actionData, /** ๋„ค๋น„๊ฒŒ์ด์…˜ ์ƒํƒœ */ navigation: state.navigation, /** ๋„ค๋น„๊ฒŒ์ด์…˜ ์ค‘ ์—ฌ๋ถ€ */ isNavigating: state.navigation.state === "loading", /** Action ์ œ์ถœ ์ค‘ ์—ฌ๋ถ€ */ isSubmitting: state.navigation.state === "submitting", /** ํ”„๋กœ๊ทธ๋ž˜๋งคํ‹ฑ ๋„ค๋น„๊ฒŒ์ด์…˜ */ navigate: navigateFn, /** Action ์ œ์ถœ */ submit: submitFn, }; }