├── .gitignore ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── components.tsx ├── index.ts └── route-parser.d.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | dist/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Nicholas Cuthbert 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # react-elmish-router 3 | 4 | ## Setup 5 | 6 | In your types folder, create the following type definitions: 7 | ```typescript 8 | // This type enumerates all the valid pages in the application 9 | export type Route = 10 | | 'HELLO' 11 | | 'PAGE1' 12 | | 'PAGE2' 13 | | 'PAGE_N' 14 | ; 15 | 16 | // Extend your domain state with the router state, this contains all the routing information 17 | export type State = 18 | DomainState 19 | & RouterState; 20 | 21 | export type Action = 22 | // Extend your domain actions with router actions 23 | | DomainActions 24 | | RouterAction; 25 | ``` 26 | 27 | Now you need to create a mapping from the set of routes, to matching urls. This uses the syntax from https://github.com/rcs/route-parser 28 | ```typescript 29 | export const routeDefinitions = { 30 | 'HELLO': '/hello', 31 | 'PAGE1': '/page1', 32 | 'PAGE2': '/page/2', 33 | 'PAGE_N': '/page/more/:pageNumber' 34 | }; 35 | ``` 36 | 37 | In your elmish initialization function, need to initialize the router: 38 | ```typescript 39 | function intializer(): StateEffectPair { 40 | const [state, action] = initializeRouter, Action>(routeDefinitions, [{ 41 | /* Your domain state here */ 42 | }, Effects.none()]); 43 | 44 | return [state, action]; 45 | } 46 | ``` 47 | 48 | Finally in your reducer, you need to handle the `ROUTER` action type. You can of course, extend the router reduce, by handling a subset of the event types. The most useful of which is probably `URL_PATHNAME_UPDATED`. `URL_PATHNAME_UPDATED` is called on page load. 49 | ```typescript 50 | case 'ROUTER': { 51 | const [nextState, nextEffects] = routerReducer(prev, action); 52 | if(action.subtype === 'URL_PATHNAME_UPDATED') { 53 | switch(action.route) { 54 | case 'HELLO': // do stuff here 55 | case 'PAGE1': // do stuff here 56 | case 'PAGE2': // do stuff here 57 | case 'PAGE_N': // do stuff here 58 | case false: // no matching pages 59 | default: throwIfNotNever(action.route); // Should never hit the default case 60 | } 61 | } else { 62 | return [nextState, nextEffects]; 63 | } 64 | } 65 | ``` 66 | 67 | ## Usage 68 | React Elmish Router provides various ways to navigate between routes. You can use the effect creators, `goBackEffect`, `goForwardEffect` and `navigateEffect`, or dispatch navigate events using `dispatchGoBack`, `dispatchGoForward` and `dispatchNavigate`, or alternatively use the `Link`, and `Back` and `Forward` react components to dispatch events for you 69 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-elmish-router", 3 | "version": "2.0.0", 4 | "description": "Reducer first router for react-use-elmish", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "test-only": "jest", 9 | "watch": "jest --watch --coverage", 10 | "test": "jest --coverage --coverageReporters=text-lcov | coveralls", 11 | "prepack": "tsc" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/ncthbrt/react-use-elmish.git" 16 | }, 17 | "homepage": "https://github.com/ncthbrt/react-use-elmish", 18 | "author": "Nick Cuthbert", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/ncthbrt/react-use-elmish/issues" 22 | }, 23 | "devDependencies": { 24 | "@testing-library/react-hooks": "^1.1.0", 25 | "@types/jest": "^24.0.16", 26 | "@types/react": "^16.8.23", 27 | "@types/react-dom": "^16.8.5", 28 | "coveralls": "^3.0.5", 29 | "jest": "^24.8.0", 30 | "react-test-renderer": "^16.8.6", 31 | "source-map-loader": "^0.2.4", 32 | "ts-jest": "^24.0.2", 33 | "ts-loader": "^6.0.4", 34 | "typescript": "^3.7.2" 35 | }, 36 | "dependencies": { 37 | "route-parser": "^0.0.5" 38 | }, 39 | "peerDependencies": { 40 | "react": "^16.8.6", 41 | "react-use-elmish": "^1.0.0" 42 | } 43 | } -------------------------------------------------------------------------------- /src/components.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouterAction, ParsedHash, ParsedSearch, dispatchNavigate, dispatchGoBack, dispatchGoForward } from './index'; 3 | import { Dispatch } from 'react-use-elmish'; 4 | 5 | 6 | /* Type resolves to dispatch & every prop of the element except for 'href' */ 7 | type RouterLinkProps = { 8 | dispatch: Dispatch>; 9 | } & Omit< 10 | React.DetailedHTMLProps< 11 | React.AnchorHTMLAttributes, 12 | HTMLAnchorElement 13 | >, 14 | 'href' 15 | >; 16 | 17 | export function Link({ 18 | dispatch, 19 | route, 20 | pushHistory, 21 | match, 22 | hash, 23 | search, 24 | ...rest 25 | }: RouterLinkProps & { 26 | route: Route; 27 | match?: { [key: string]: string | undefined }; 28 | pushHistory?: boolean; 29 | hash?: ParsedHash; 30 | search?: ParsedSearch; 31 | }) { 32 | return ( 33 | dispatchNavigate(route, pushHistory ?? false, match ?? {}, dispatch, hash, search)} 35 | {...rest} 36 | /> 37 | ); 38 | } 39 | 40 | export function Back({ dispatch, ...props }: RouterLinkProps) { 41 | return ( 42 | dispatchGoBack(dispatch)} 44 | href="#" 45 | {...props} 46 | /> 47 | ); 48 | } 49 | 50 | export function Forward({ dispatch, ...props }: RouterLinkProps) { 51 | return ( 52 | dispatchGoForward(dispatch)} 54 | {...props} 55 | /> 56 | ); 57 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Note: require is *necessary* as 'route-parser' uses a different module format. 2 | // If you change this code to use `import ParsedRoute from 'router-parser';` it *will* break. 3 | import ParsedRoute = require('route-parser'); 4 | import { Dispatch, Effect, Effects, StateEffectPair } from 'react-use-elmish'; 5 | 6 | export type Match = { [key: string]: string }; 7 | type UnfilteredMatch = { [key: string]: string | undefined }; 8 | 9 | export type ParsedHash = { [key: string]: string | true } | false; 10 | export type ParsedSearch = { [key: string]: string | true } | false; 11 | 12 | function assertTrue(condition: boolean, reason: string) { 13 | if (!condition) { 14 | throw new Error(reason); 15 | } 16 | } 17 | 18 | function parseHashOrSearch( 19 | x: string 20 | ): { [key: string]: string | true } | false { 21 | if (x === '' || x === '#' || x === '?') { 22 | return false; 23 | } else { 24 | return x 25 | .substr(1) 26 | .split('&') 27 | .map(el => el.split('=')) 28 | .reduce( 29 | (prev, curr) => ({ 30 | ...prev, 31 | [curr[0]]: curr[1] !== '' ? decodeURIComponent(curr[1]) : true, 32 | }), 33 | {} 34 | ); 35 | } 36 | } 37 | 38 | function buildHash(values: ParsedHash): string { 39 | if (!values || Object.keys(values).length == 0) { 40 | return ''; 41 | } else { 42 | const keyValuePairs = Object.keys(values).map(key => { 43 | if (values[key] == true) { 44 | return key; 45 | } 46 | return `${key}=${encodeURIComponent(values[key])}`; 47 | }); 48 | 49 | return keyValuePairs.join('&'); 50 | } 51 | } 52 | 53 | export function filterMatch(obj: UnfilteredMatch) { 54 | return Object.keys(obj) 55 | .filter(x => obj[x]) 56 | .reduce((prev, key) => ({ ...prev, [key]: obj[key]! }), {} as Match); 57 | } 58 | 59 | export type RouteFromRouteDefinitions< 60 | RouteDefintion extends { [route: string]: string } 61 | > = keyof RouteDefintion & string; 62 | 63 | export type RouteDefinitionsFromRoute = { 64 | [R in Route]: string; 65 | }; 66 | 67 | type ParsedRoutes = { 68 | name: Route; 69 | pattern: string; 70 | parsed: ParsedRoute; 71 | }[]; 72 | 73 | export type RouterState = { 74 | router: { 75 | currentRoute: Route | false; 76 | currentMatch: Match; 77 | pathname: string; 78 | hash: string; 79 | parsedHash: ParsedHash; 80 | parsedSearch: ParsedSearch; 81 | search: string; 82 | _routes: ParsedRoutes; 83 | }; 84 | }; 85 | 86 | export type PathnameUpdatedAction = { 87 | type: 'ROUTER'; 88 | subtype: 'URL_PATHNAME_UPDATED'; 89 | pathname: string; 90 | route: Route | false; 91 | parsedHash: ParsedHash; 92 | parsedSearch: ParsedSearch; 93 | match: Match; 94 | hash: string; 95 | search: string; 96 | }; 97 | 98 | type NavigateToRouteAction = { 99 | type: 'ROUTER'; 100 | subtype: 'NAVIGATE_TO_ROUTE'; 101 | route: Route; 102 | pushHistory: boolean; 103 | match: Match; 104 | hash?: ParsedHash; 105 | search?: ParsedSearch; 106 | }; 107 | 108 | type NavigateBackAction = { type: 'ROUTER'; subtype: 'NAVIGATE_BACK' }; 109 | 110 | type NavigateForwardAction = { type: 'ROUTER'; subtype: 'NAVIGATE_FORWARD' }; 111 | 112 | export type RouterAction = 113 | | PathnameUpdatedAction 114 | | NavigateToRouteAction 115 | | NavigateBackAction 116 | | NavigateForwardAction; 117 | 118 | function encodeMatches(matches: Match) { 119 | return Object.keys(matches).reduce((prev, curr) => { 120 | const value = matches[curr]; 121 | if (value) 122 | prev[curr] = encodeURIComponent(value); 123 | 124 | return prev; 125 | }, {} as Match); 126 | } 127 | 128 | function decodeMatches(matches: Match) { 129 | return Object.keys(matches).filter(x => matches[x]).reduce((prev, curr) => { 130 | prev[curr] = decodeURIComponent(matches[curr]); 131 | return prev; 132 | }, {} as Match); 133 | } 134 | 135 | export function navigateEffect( 136 | route: Route, 137 | pushHistory: boolean, 138 | matches: UnfilteredMatch, 139 | state: RouterState, 140 | hash: ParsedHash = {}, 141 | ) { 142 | return Effects.dispatchFromFunction>(() => { 143 | const r = state.router._routes.find(x => x.name == route)!; 144 | const path = r.parsed.reverse(encodeMatches(filterMatch(matches))); 145 | assertTrue( 146 | !!path, 147 | `${route} should match the format '${ 148 | r.pattern 149 | }' but only received the following parameters ${JSON.stringify( 150 | matches 151 | )} ` 152 | ); 153 | const builtHash = buildHash(hash ?? {}); 154 | const pathWithHashAndSearch = path + (builtHash === '' ? '' : '#' + builtHash); 155 | if (pushHistory) { 156 | window.history.pushState({}, '', pathWithHashAndSearch); 157 | } else { 158 | window.history.replaceState({}, '', pathWithHashAndSearch); 159 | } 160 | return ({ 161 | type: 'ROUTER', 162 | subtype: 'URL_PATHNAME_UPDATED', 163 | pathname: path, 164 | route: route, 165 | match: filterMatch(matches), 166 | hash: window.location.hash, 167 | search: window.location.search, 168 | parsedHash: parseHashOrSearch(window.location.hash), 169 | parsedSearch: parseHashOrSearch(window.location.search), 170 | }); 171 | }, (err) => { 172 | console.error('FAILED TO ROUTE DUE TO ', err); 173 | return ({ 174 | type: 'ROUTER', 175 | subtype: 'URL_PATHNAME_UPDATED', 176 | pathname: window.location.pathname + window.location.search, 177 | route: false, 178 | match: {}, 179 | hash: window.location.hash, 180 | search: window.location.search, 181 | parsedHash: parseHashOrSearch(window.location.hash), 182 | parsedSearch: parseHashOrSearch(window.location.search), 183 | }) 184 | }); 185 | } 186 | 187 | export function goBackEffect() { 188 | return [ 189 | (_: unknown) => { 190 | window.history.back(); 191 | }, 192 | ]; 193 | } 194 | 195 | export function goForwardEffect() { 196 | return [ 197 | (_: unknown) => { 198 | window.history.forward(); 199 | }, 200 | ]; 201 | } 202 | 203 | function parseRoutes( 204 | routes: RouteDefinitions 205 | ): ParsedRoutes> { 206 | return Object.keys(routes).sort().reduce( 207 | (prev, route) => [ 208 | ...prev, 209 | { 210 | name: route as RouteFromRouteDefinitions, 211 | pattern: routes[route], 212 | parsed: new ParsedRoute(routes[route]), 213 | }, 214 | ], 215 | [] as ParsedRoutes> 216 | ); 217 | } 218 | 219 | function findMatchingRoute( 220 | pathname: string, 221 | routes: ParsedRoutes 222 | ): [Match, Route | false] { 223 | for (const route in routes) { 224 | const routeInfo = routes[route]; 225 | const match = routeInfo.parsed.match(pathname); 226 | if (!!match) { 227 | return [decodeMatches(match), routeInfo.name]; 228 | } 229 | } 230 | return [{}, false]; 231 | } 232 | 233 | export function routerReducer< 234 | State extends RouterState, 235 | Route extends string, 236 | Action extends RouterAction 237 | >(prev: State, action: RouterAction): StateEffectPair { 238 | switch (action.subtype) { 239 | case 'URL_PATHNAME_UPDATED': 240 | return [ 241 | { 242 | ...prev, 243 | router: { 244 | ...prev.router, 245 | currentRoute: action.route || false, 246 | currentMatch: action.match, 247 | pathname: action.pathname, 248 | hash: action.hash, 249 | search: action.search, 250 | }, 251 | }, 252 | Effects.none(), 253 | ]; 254 | case 'NAVIGATE_TO_ROUTE': 255 | return [ 256 | prev, 257 | navigateEffect( 258 | action.route, 259 | action.pushHistory, 260 | action.match, 261 | prev, 262 | action.hash 263 | ) as Effect, 264 | ]; 265 | case 'NAVIGATE_BACK': 266 | return [prev, goBackEffect()]; 267 | case 'NAVIGATE_FORWARD': 268 | return [prev, goForwardEffect()]; 269 | } 270 | } 271 | 272 | export function initializeRouter< 273 | Routes extends string, 274 | State, 275 | Action extends unknown | RouterAction 276 | >( 277 | routes: RouteDefinitionsFromRoute, 278 | stateEffectPair: StateEffectPair 279 | ): StateEffectPair< 280 | State & RouterState, 281 | Action 282 | > { 283 | const parsedRoutes = parseRoutes(routes); 284 | const pathname = window.location.pathname; 285 | const search = window.location.search; 286 | const hash = window.location.hash; 287 | const [match, route] = findMatchingRoute(pathname + search, parsedRoutes); 288 | console.log(match); 289 | 290 | const state: State & 291 | RouterState = { 292 | ...stateEffectPair[0], 293 | router: { 294 | _routes: parsedRoutes, 295 | currentRoute: route, 296 | currentMatch: match, 297 | pathname: location.pathname, 298 | hash: location.hash, 299 | search: location.search, 300 | parsedHash: parseHashOrSearch(hash), 301 | parsedSearch: parseHashOrSearch(search), 302 | }, 303 | }; 304 | 305 | const pathnameUpdatedAction = Effects.action({ 306 | type: 'ROUTER', 307 | subtype: 'URL_PATHNAME_UPDATED', 308 | match, 309 | route, 310 | pathname, 311 | search, 312 | hash, 313 | parsedHash: parseHashOrSearch(hash), 314 | parsedSearch: parseHashOrSearch(search), 315 | } as Action); 316 | 317 | return [ 318 | state, 319 | Effects.combine( 320 | pathnameUpdatedAction, 321 | listenToHistoryPopEffect(state), 322 | stateEffectPair[1] 323 | ), 324 | ]; 325 | } 326 | 327 | function listenToHistoryPopEffect< 328 | Route extends string, 329 | Action extends unknown | RouterAction 330 | >(state: RouterState): Effect { 331 | return [ 332 | (dispatch: Dispatch) => { 333 | const listener = () => { 334 | const pathname = window.location.pathname; 335 | const search = window.location.search; 336 | const hash = window.location.hash; 337 | const [match, route] = findMatchingRoute( 338 | pathname + search, 339 | state.router._routes 340 | ); 341 | dispatch({ 342 | type: 'ROUTER', 343 | subtype: 'URL_PATHNAME_UPDATED', 344 | match, 345 | route, 346 | pathname, 347 | search, 348 | hash, 349 | parsedHash: parseHashOrSearch(hash), 350 | parsedSearch: parseHashOrSearch(search), 351 | } as Action); 352 | }; 353 | window.addEventListener('popstate', listener); 354 | window.addEventListener('hashchange', listener); 355 | }, 356 | ]; 357 | } 358 | 359 | export function dispatchNavigate( 360 | route: Route, 361 | pushHistory: boolean, 362 | match: UnfilteredMatch, 363 | dispatch: Dispatch>, 364 | hash?: ParsedHash, 365 | search?: ParsedSearch 366 | ) { 367 | dispatch({ 368 | type: 'ROUTER', 369 | subtype: 'NAVIGATE_TO_ROUTE', 370 | pushHistory, 371 | match: filterMatch(match), 372 | route, 373 | hash, 374 | search, 375 | }); 376 | } 377 | 378 | export function dispatchGoBack(dispatch: Dispatch>) { 379 | dispatch({ type: 'ROUTER', subtype: 'NAVIGATE_BACK' }); 380 | } 381 | 382 | export function dispatchGoForward(dispatch: Dispatch>) { 383 | dispatch({ type: 'ROUTER', subtype: 'NAVIGATE_FORWARD' }); 384 | } 385 | 386 | -------------------------------------------------------------------------------- /src/route-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'route-parser' { 2 | class Routes { 3 | constructor(route: string); 4 | match(path: string): { [params: string]: string } | false; 5 | reverse(params: { [property: string]: string | number }): string; 6 | } 7 | export = Routes; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "./dist/", 5 | "lib": ["es2017", "es7", "es6", "dom"], 6 | "noImplicitAny": true, 7 | "strict": true, 8 | "module": "commonjs", 9 | "noImplicitReturns": true, 10 | "jsx": "react", 11 | "noFallthroughCasesInSwitch": true, 12 | "esModuleInterop": true, 13 | "target": "es6" 14 | }, 15 | "include": ["./src/"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | --------------------------------------------------------------------------------