Skip to content

Instantly share code, notes, and snippets.

@den-churbanov
Last active January 31, 2024 10:46
Show Gist options
  • Save den-churbanov/84568503322ed7e5beeb24c348098e31 to your computer and use it in GitHub Desktop.
Save den-churbanov/84568503322ed7e5beeb24c348098e31 to your computer and use it in GitHub Desktop.
Effector bindings for react-router-dom v.6.2.1
import { useContext } from 'react';
import { createStore, createEffect, createEvent, sample, attach, combine, restore } from 'effector';
import { spread, debug, not, and, empty, or } from 'patronum';
import { createGate, useGate } from 'effector-react';
import {
useLocation,
useNavigate,
matchPath,
generatePath,
RouteMatch,
matchRoutes,
UNSAFE_NavigationContext as NavigationContext,
} from 'react-router-dom';
import type { History, Transition } from 'history';
import type { Event, Store } from 'effector';
import type { NavigateFunction, Location } from 'react-router-dom';
import { atom, toStore } from '@/shared/effector';
import { routesConfig } from '@/router';
import { parseRouteParams } from './utils';
import type {
AppGateProps,
EffectorRoute,
RoutesMatchingFunction,
NavigateEventPayload,
NavigateFxPayload,
NavigateParams,
RouteNavigatePayload,
RouteParams,
RouteQuery
} from './types';
const defaultMatchingFunction: RoutesMatchingFunction = (next, current) => next.pathname !== current.pathname;
const router = atom(() => {
const AppGate = createGate<AppGateProps>(`AppGate`);
const $navigate = createStore<NavigateFunction>(null);
const $history = createStore<History>(null);
const $pathname = createStore('');
const $hash = createStore('');
const $search = createStore('');
const $state = createStore<any>(null);
const $key = createStore('');
spread({
source: AppGate.state,
targets: {
navigate: $navigate,
history: $history,
location: spread({
pathname: $pathname,
hash: $hash,
search: $search,
state: $state,
key: $key
})
}
});
const $matches = createStore<RouteMatch[]>([]);
const $location = combine<Location>({
pathname: $pathname,
hash: $hash,
search: $search,
state: $state,
key: $key
});
const navigate = createEvent<NavigateEventPayload>();
const navigateFx = attach({
source: $navigate,
mapParams: (params: NavigateParams, navigate) => ({ ...params, navigate }),
effect: createEffect(({ navigate, to, ...options }: NavigateFxPayload) => navigate(to, options))
});
sample({
clock: navigate,
fn: (payload): NavigateParams => typeof payload === 'string' ? { to: payload } : payload,
target: navigateFx
});
sample({
clock: $location,
fn: location => matchRoutes(routesConfig, location),
target: $matches
});
return {
$history,
$location,
$matches,
navigate,
navigateFx,
useSetup() {
const history = useContext(NavigationContext)?.navigator as History;
const navigate = useNavigate();
const location = useLocation();
useGate(AppGate, { navigate, location, history });
}
}
});
// Public API
export const useRouterSetup = router.useSetup;
// TODO add parent route option with params inheritance
export function createRoute<
Params extends RouteParams = RouteParams,
Query extends RouteQuery = RouteQuery
>(
path: string,
debugging?: boolean
) {
const $matches = router.$matches
const routerNavigate = router.navigate;
//#region stores
const $path = toStore(path);
const $params = createStore<Params>({} as Params);
const $query = createStore<Query>({} as Query);
const $match = sample({
clock: $matches,
source: $path,
fn: (path, matches) => {
return matches.find(match => matchPath(path, match.pathname)) ?? null;
}
});
const $opened = sample({
clock: $match,
fn: match => match !== null
});
//#endregion
//#region open/updated/closed events
const $updates = createStore(false);
sample({
clock: $match,
filter: $opened,
fn: match => parseRouteParams(match.params),
target: $params
});
const closed: Event<Params> = sample({
clock: $opened,
source: $params,
filter: not($opened),
fn: params => params
});
const opened: Event<Params> = sample({
clock: $opened,
source: $params,
filter: $opened,
fn: params => params
});
const updated: Event<Params> = sample({
clock: $match,
source: $params,
filter: and($updates, not(empty($match))),
fn: params => params
});
sample({
clock: opened,
fn: () => true,
target: $updates
});
//#endregion open/updated/closed events
//#region handling navigation
const open = createEvent<Params>();
const replace = createEvent<Params>();
const navigate = createEvent<RouteNavigatePayload<Params, Query>>();
sample({
clock: open,
source: $path,
fn: (path, params) => generatePath(path, params),
target: routerNavigate
});
sample({
clock: replace,
source: $path,
fn: (path, params) => ({
to: generatePath(path, params),
replace: true
}),
target: routerNavigate
});
// TODO учитывать текущий и следующий query, replace, state
sample({
clock: navigate,
source: $path,
fn: (path, { params, query, replace, state }) => generatePath(path, params),
target: routerNavigate
});
//#endregion handling navigation
// resets
$params.reset(closed);
$query.reset(closed);
$updates.reset(closed);
if (debugging) {
debug({
closed,
opened,
updated
})
}
const route: EffectorRoute<Params, Query> = {
$params,
$query,
$opened,
opened,
updated,
closed,
open,
replace,
navigate
}
Object.freeze(route);
return route;
}
export function createRouterBlocker(
$when: Store<boolean>,
// return true when need block navigation
shouldBlock: RoutesMatchingFunction = defaultMatchingFunction
) {
const navigateFx = router.navigateFx;
const $location = router.$location;
const $history = router.$history;
const blockNavigation = createEvent<Transition>(); // blocker
const confirmNavigation = createEvent();
const cancelNavigation = createEvent();
const $isBlocked = createStore(false);
const $lastTransition = createStore<Transition>(null);
const $confirmedNavigation = createStore(false);
$isBlocked.reset(cancelNavigation, confirmNavigation);
sample({
clock: confirmNavigation,
fn: () => true,
target: $confirmedNavigation
});
const localNavigateFx = attach({ effect: navigateFx });
sample({
clock: blockNavigation,
source: { location: $location, confirmed: $confirmedNavigation },
fn: ({ location, confirmed }, tx) => {
const isBlocked = !confirmed && shouldBlock(tx.location, location);
return {
tx,
isBlocked,
confirmed: isBlocked ? confirmed : true
}
},
target: spread({
tx: $lastTransition,
isBlocked: $isBlocked,
confirmed: $confirmedNavigation
})
});
sample({
clock: $confirmedNavigation,
source: $lastTransition,
filter: (tx, confirmed) => confirmed && tx !== null,
fn: ({ location }) => {
const state = typeof location === 'object' && 'state' in location ? location.state : undefined;
return {
to: location,
state
}
},
target: localNavigateFx
});
const blockFx = attach({
source: $history,
effect(history) {
function blockCallback(tx: Transition) {
const autoUnblockingTx = {
...tx,
retry() {
unblock();
tx.retry();
},
};
blockNavigation(autoUnblockingTx);
}
const unblock = history.block(blockCallback);
return unblock;
}
});
const $unblock = restore(blockFx, null);
const unblockFx = attach({
source: $unblock,
effect(unblock) {
unblock?.()
}
});
sample({
clock: [$when, $location],
filter: $when,
target: blockFx
});
sample({
clock: [$when, $confirmedNavigation],
filter: or($confirmedNavigation, not($when)),
target: unblockFx
});
$confirmedNavigation.reset(localNavigateFx.finally);
$unblock.reset(unblockFx.finally);
return Object.freeze({
$isBlocked: $isBlocked as Store<boolean>,
confirmNavigation,
cancelNavigation
});
}
import type { History } from 'history';
import type { Location, NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import type { Event, EventCallable, Store } from 'effector';
export type RouteParams = Record<string, any>;
export type RouteQuery = Record<string, any>;
export interface AppGateProps {
navigate: NavigateFunction,
location: Location,
history: History
}
export interface NavigateParams extends NavigateOptions {
to: To
}
export interface NavigateFxPayload extends NavigateParams {
navigate: NavigateFunction
}
export type NavigateEventPayload = string | NavigateParams;
export interface RouteNavigatePayload<Params extends RouteParams, Query extends RouteQuery> extends NavigateOptions {
params: Params,
query?: Query
}
export interface EffectorRoute<Params extends RouteParams = RouteParams, Query extends RouteQuery = RouteQuery> {
$params: Store<Params>,
$query: Store<Query>,
$opened: Store<boolean>,
opened: Event<Params>,
updated: Event<Params>,
closed: Event<Params>,
open: EventCallable<Params>,
replace: EventCallable<Params>,
navigate: EventCallable<RouteNavigatePayload<Params, Query>>
}
// block navigation
export type RoutesMatchingFunction = (next: Location, current: Location) => boolean;
import { matchPath } from 'react-router-dom';
import type { Location, Params as RouterParams } from 'react-router-dom';
import { isBooleanString, isNumber, parseBooleanString } from '@/helpers';
import { RouteParams } from './types';
export const joinPaths = (...paths: string[]): string => paths.join('/').replace(/\/\/+/g, '/');
function parseParamByType(value: string) {
if (isNumber(value)) return Number(value);
if (isBooleanString(value)) return parseBooleanString(value);
return value;
}
export function getRouteParams<Params extends RouteParams>(pattern: string, location: Location): Params {
const match = matchPath(pattern, location.pathname);
return parseRouteParams(match.params);
}
export function parseRouteParams<Params extends RouteParams>(params: RouterParams): Params {
const typedParams: Record<string, any> = {};
for (const key in params) {
typedParams[key] = parseParamByType(params[key]);
}
return typedParams as Params;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment