├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── nodejs.yml │ ├── pages.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .postcssrc.json ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── App.tsx ├── Demo.tsx ├── DemoPage.tsx ├── index.css ├── index.html └── index.tsx ├── eslint.config.mjs ├── jest.config.js ├── package-lock.json ├── package.json ├── src └── index.ts ├── tailwind.config.js ├── tests └── geolocated.test.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | groups: 10 | eslint: 11 | patterns: 12 | - "@eslint/*" 13 | - "@typescript-eslint/*" 14 | - "eslint-*" 15 | - "eslint" 16 | - "typescript-eslint" 17 | jest: 18 | patterns: 19 | - "@types/jest" 20 | - "jest" 21 | - "jest-environment-jsdom" 22 | - "ts-jest" 23 | react: 24 | patterns: 25 | - "@types/react" 26 | - "react" 27 | - "react-dom" 28 | tailwind: 29 | patterns: 30 | - "@tailwindcss/*" 31 | - "tailwindcss" 32 | testing-library: 33 | patterns: 34 | - "@testing-library/*" 35 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v3 9 | - name: Setup Node.js 10 | uses: actions/setup-node@v3 11 | with: 12 | node-version-file: .nvmrc 13 | - name: Install dependencies 14 | run: npm install 15 | - name: Build 16 | run: npm run dist 17 | - name: Test 18 | run: npm run test 19 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Pages 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | actions: read 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version-file: .nvmrc 26 | - name: Install dependencies 27 | run: npm install 28 | - name: Build pages 29 | run: "npm run docs:build" 30 | - name: Upload artifact 31 | uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: ./demo/dist 34 | 35 | deploy: 36 | environment: 37 | name: github-pages 38 | url: ${{ steps.deployment.outputs.page_url }} 39 | runs-on: ubuntu-latest 40 | needs: build 41 | steps: 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v4 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write # to be able to publish a GitHub release 12 | issues: write # to be able to comment on released issues 13 | pull-requests: write # to be able to comment on released pull requests 14 | id-token: write # to enable use of OIDC for npm provenance 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version-file: .nvmrc 22 | - name: Install dependencies 23 | run: npm clean-install 24 | - name: Build 25 | run: npm run dist 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | run: npx semantic-release 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | docs/build/ 3 | demo/dist 4 | dist 5 | dist-modules/ 6 | node_modules/ 7 | coverage/ 8 | .idea/ 9 | .eslintcache 10 | npm-debug.log 11 | yarn-error.log 12 | .docz 13 | .parcel-cache 14 | .DS_store 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | demo/ 3 | dist/ 4 | docs/ 5 | docs-resources/ 6 | src/ 7 | tests/ 8 | .* 9 | webpack.* 10 | jest.config.js 11 | tailwind.config.js 12 | tsconfig.json 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 2 | -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@tailwindcss/postcss" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "tabWidth": 4, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Please see the [Releases](https://github.com/no23reason/react-geolocated/releases) page for changes on each version. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | This project uses [`semantic-release`](https://github.com/semantic-release/semantic-release). 4 | Please use [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) when creating commits. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Dan Homola 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Node.js CI](https://github.com/no23reason/react-geolocated/workflows/Node.js%20CI/badge.svg) [![npm version](https://img.shields.io/npm/v/react-geolocated.svg)](https://www.npmjs.com/package/react-geolocated) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 2 | 3 | # react-geolocated - React hook for using [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation) 4 | 5 | ## Demo 6 | 7 | Basic demo can be found at the [demo page](https://no23reason.github.io/react-geolocated/). 8 | 9 | ## HOC version 10 | 11 | This package used to be a HOC, not a hook. If you want to use the HOC version, please stick with versions < 4. 12 | 13 | ## Basic Usage 14 | 15 | Install using `npm`: 16 | 17 | ```bash 18 | npm install react-geolocated --save 19 | ``` 20 | 21 | Then use in your application like this: 22 | 23 | ```jsx 24 | import React from "react"; 25 | import { useGeolocated } from "react-geolocated"; 26 | 27 | const Demo = () => { 28 | const { coords, isGeolocationAvailable, isGeolocationEnabled } = 29 | useGeolocated({ 30 | positionOptions: { 31 | enableHighAccuracy: false, 32 | }, 33 | userDecisionTimeout: 5000, 34 | }); 35 | 36 | return !isGeolocationAvailable ? ( 37 |
Your browser does not support Geolocation
38 | ) : !isGeolocationEnabled ? ( 39 |
Geolocation is not enabled
40 | ) : coords ? ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
latitude{coords.latitude}
longitude{coords.longitude}
altitude{coords.altitude}
heading{coords.heading}
speed{coords.speed}
65 | ) : ( 66 |
Getting the location data…
67 | ); 68 | }; 69 | 70 | export default Demo; 71 | ``` 72 | 73 | ## Hook return value 74 | 75 | The values returned from the hook are: 76 | 77 | ```js 78 | { 79 | coords: { 80 | latitude, 81 | longitude, 82 | altitude, 83 | accuracy, 84 | altitudeAccuracy, 85 | heading, 86 | speed, 87 | }, 88 | timestamp, // timestamp of when the last position was retrieved 89 | isGeolocationAvailable, // boolean flag indicating that the browser supports the Geolocation API 90 | isGeolocationEnabled, // boolean flag indicating that the user has allowed the use of the Geolocation API 91 | positionError, // object with the error returned from the Geolocation API call 92 | getPosition, // a callback you can use to trigger the location query manually 93 | } 94 | ``` 95 | 96 | The `coords` value is equivalent to the [Coordinates](https://developer.mozilla.org/en-US/docs/Web/API/Coordinates) object and the `positionError` is equivalent to the [PositionError](https://developer.mozilla.org/en-US/docs/Web/API/PositionError). 97 | 98 | ## Configuration 99 | 100 | The `useGeolocated` hook takes optional configuration parameter: 101 | 102 | ```js 103 | { 104 | positionOptions: { 105 | enableHighAccuracy: true, 106 | maximumAge: 0, 107 | timeout: Infinity, 108 | }, 109 | watchPosition: false, 110 | userDecisionTimeout: null, 111 | suppressLocationOnMount: false, 112 | geolocationProvider: navigator.geolocation, 113 | isOptimisticGeolocationEnabled: true, 114 | watchLocationPermissionChange: false, 115 | onError, 116 | onSuccess, 117 | } 118 | ``` 119 | 120 | The `positionOptions` object corresponds to the [PositionOptions](https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions) of the Geolocation API. 121 | 122 | By default the hook only sets position once. To watch the user's position and provide live updates to position, set `watchPosition = true`. The geolocation event handler is unregistered when the hook unmounts. 123 | 124 | If set, the `userDecisionTimeout` determines how much time (in milliseconds) we give the user to make the decision whether to allow to share their location or not. In Firefox, if the user declines to use their location, the Geolocation API call does not end with an error. Therefore we want to fallback to the error state if the user declines and the API does not tell us. 125 | 126 | The location is obtained when the hook mounts by default. If you want to prevent this and get the location later, set the `suppressLocationOnMount` to `true` and use the `getPosition` function returned by the hook to trigger the geolocation query manually. 127 | 128 | The `geolocationProvider` allows to specify alternative source of the geolocation API. This was added mainly for testing purposes, however feel free to use it if need be. 129 | 130 | The `isOptimisticGeolocationEnabled` allows you to set the default value of `isGeolocationEnabled`. By default it is `true`, which means `isGeolocationEnabled` will be `true` on first render. There may be cases where you don't want to assume that the user will give permission, ie you want the first value to for `isGeolocationEnabled` to be `false`. In that case, you can set `isOptimisticGeolocationEnabled` to `false`. 131 | 132 | The `watchLocationPermissionChange` allows you to watch for changes in the geolocation permissions on browsers that support the permissions API. When set to `true`, the hook will set a watch on the geolocation permission so that when this permission changes, the location will be obtained again unless the `suppressLocationOnMount` is also set to `true`. 133 | 134 | The `onError` callback is called when the geolocation query fails or when the time for the user decision passes. 135 | The `onSuccess` is called when the geolocation query succeeds. 136 | 137 | ## Browser support 138 | 139 | The package supports all the browsers with ES6 support (i.e. any modern browser). If you need to support IE11, stick to version < 4 of this package. 140 | 141 | ## Acknowledgements 142 | 143 | Many thanks belong to [@mcumpl](https://github.com/mcumpl) for the original idea for this as well as many suggestions and comments. 144 | 145 | This project uses the [react-component-boilerplate](https://github.com/survivejs/react-component-boilerplate). 146 | 147 | ## License 148 | 149 | _react-geolocated_ is available under MIT. See [LICENSE](https://github.com/no23reason/react-geolocated/tree/master/LICENSE) for more details. 150 | -------------------------------------------------------------------------------- /demo/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import GithubCorner from "react-github-corner"; 3 | 4 | import { DemoPage } from "./DemoPage"; 5 | 6 | export const App = () => { 7 | return ( 8 |
9 | 10 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /demo/Demo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useGeolocated } from "../src"; 3 | 4 | const getDirection = (degrees: number, isLongitude: boolean) => 5 | degrees > 0 ? (isLongitude ? "E" : "N") : isLongitude ? "W" : "S"; 6 | 7 | // adapted from http://stackoverflow.com/a/5786281/2546338 8 | const formatDegrees = (degrees: number, isLongitude: boolean) => 9 | `${0 | degrees}° ${ 10 | 0 | (((degrees < 0 ? (degrees = -degrees) : degrees) % 1) * 60) 11 | }' ${0 | (((degrees * 60) % 1) * 60)}" ${getDirection( 12 | degrees, 13 | isLongitude, 14 | )}`; 15 | 16 | export const Demo = () => { 17 | const { 18 | coords, 19 | getPosition, 20 | isGeolocationAvailable, 21 | isGeolocationEnabled, 22 | positionError, 23 | } = useGeolocated({ 24 | positionOptions: { 25 | enableHighAccuracy: false, 26 | }, 27 | userDecisionTimeout: 5000, 28 | watchLocationPermissionChange: true, 29 | }); 30 | 31 | return ( 32 |
33 |
34 |
35 | {!isGeolocationAvailable ? ( 36 |
Your browser does not support Geolocation.
37 | ) : !isGeolocationEnabled ? ( 38 |
Geolocation is not enabled.
39 | ) : coords ? ( 40 |
41 | You are at{" "} 42 | 43 | {formatDegrees(coords.latitude, false)} 44 | 45 | ,{" "} 46 | 47 | {formatDegrees(coords.longitude, true)} 48 | 49 | {coords.altitude ? ( 50 | 51 | , approximately {coords.altitude} meters 52 | above sea level 53 | 54 | ) : null} 55 | . 56 |
57 | ) : ( 58 |
Getting the location data…
59 | )} 60 | {!!positionError && ( 61 |
62 |
63 | Last position error: 64 |
{JSON.stringify(positionError)}
65 |
66 | )} 67 |
68 |
69 |
70 | 77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /demo/DemoPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Demo } from "./Demo"; 3 | 4 | export const DemoPage = () => { 5 | return ( 6 |
7 |

react-geolocated

8 |

9 | React.js hook for using{" "} 10 | 14 | Geolocation API 15 | 16 |

17 |

Elevator pitch

18 |

19 | react-geolocated is a{" "} 20 | 24 | configurable 25 | {" "} 26 | React Hook that makes using the Geolocation API 27 | easy, abstracting away some browser-specific quirks (differences 28 | on how they handle permissions for example). 29 |

30 |

Demo

31 | 32 |

33 | The{" "} 34 | 38 | demo source code 39 | {" "} 40 | is available on{" "} 41 | 45 | GitHub 46 | 47 | , as well as the{" "} 48 | 52 | README 53 | {" "} 54 | with configuration options and other details. 55 |

56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-geolocated 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { App } from "./App"; 4 | 5 | const container = document.getElementById("root"); 6 | const root = createRoot(container!); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import hooksPlugin from "eslint-plugin-react-hooks"; 4 | 5 | export default tseslint.config( 6 | eslint.configs.recommended, 7 | tseslint.configs.recommended, 8 | tseslint.configs.strict, 9 | tseslint.configs.stylistic, 10 | tseslint.configs.recommendedTypeChecked, 11 | tseslint.configs.strictTypeChecked, 12 | tseslint.configs.stylisticTypeChecked, 13 | { 14 | languageOptions: { 15 | parserOptions: { 16 | projectService: true, 17 | tsconfigRootDir: import.meta.dirname, 18 | }, 19 | }, 20 | }, 21 | { 22 | plugins: { 23 | "react-hooks": hooksPlugin, 24 | }, 25 | rules: hooksPlugin.configs.recommended.rules, 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-geolocated", 3 | "description": "React hook for using Geolocation API", 4 | "author": "Dan Homola", 5 | "user": "no23reason", 6 | "version": "0.0.0-semantic-release", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/no23reason/react-geolocated.git" 10 | }, 11 | "homepage": "https://no23reason.github.io/react-geolocated/", 12 | "bugs": { 13 | "url": "https://github.com/no23reason/react-geolocated/issues" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "reactjs", 18 | "geolocation", 19 | "hook", 20 | "react-hook" 21 | ], 22 | "license": "MIT", 23 | "main": "dist-modules", 24 | "typings": "./dist-modules/index.d.ts", 25 | "sideEffects": false, 26 | "publishConfig": { 27 | "provenance": true 28 | }, 29 | "scripts": { 30 | "dist": "rimraf ./dist-modules && tsc -p .", 31 | "prepublishOnly": "npm run dist", 32 | "prepush": "npm run test:lint && npm run test:js", 33 | "precommit": "pretty-quick --staged", 34 | "semantic-release": "semantic-release", 35 | "start": "npm run docs:dev", 36 | "test:js": "jest --coverage --testPathPattern './tests'", 37 | "test:lint": "eslint ./src --cache", 38 | "test:tdd": "jest --watch", 39 | "test": "npm run test:lint && npm run test:js", 40 | "docs:dev": "parcel --target docs_dev", 41 | "docs:build": "parcel build --target docs" 42 | }, 43 | "targets": { 44 | "main": false, 45 | "docs": { 46 | "source": "demo/index.html", 47 | "distDir": "demo/dist", 48 | "scopeHoist": false, 49 | "publicUrl": "/react-geolocated/" 50 | }, 51 | "docs_dev": { 52 | "source": "demo/index.html", 53 | "distDir": "demo/dist" 54 | } 55 | }, 56 | "browserslist": "> 0.5%, last 2 versions, not dead", 57 | "peerDependencies": { 58 | "react": ">= 16.8.0 < 20.0.0" 59 | }, 60 | "devDependencies": { 61 | "@eslint/js": "^9.17.0", 62 | "@tailwindcss/postcss": "^4.0.9", 63 | "@testing-library/jest-dom": "^6.0.0", 64 | "@testing-library/react": "^16.0.0", 65 | "@types/jest": "^29.2.1", 66 | "@types/react": "^19.0.1", 67 | "autoprefixer": "^10.4.2", 68 | "eslint": "^9.20.1", 69 | "eslint-plugin-react-hooks": "^5.1.0", 70 | "jest": "^29.2.2", 71 | "jest-environment-jsdom": "^29.2.2", 72 | "parcel": "^2.9.0", 73 | "postcss": "^8.4.5", 74 | "prettier": "~3.5.0", 75 | "pretty-quick": "^4.0.0", 76 | "process": "^0.11.10", 77 | "react": "^19.0.0", 78 | "react-dom": "^19.0.0", 79 | "react-github-corner": "^2.1.0", 80 | "rimraf": "^6.0.1", 81 | "semantic-release": "^24.0.0", 82 | "tailwindcss": "^4.0.9", 83 | "ts-jest": "^29.0.3", 84 | "typescript": "5.8.3", 85 | "typescript-eslint": "^8.25.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | 3 | /** 4 | * The configuration options. 5 | */ 6 | export interface GeolocatedConfig { 7 | /** 8 | * The Geolocation API's positionOptions configuration object. 9 | */ 10 | positionOptions?: PositionOptions; 11 | /** 12 | * Time we give to the user to allow the use of Geolocation API before presuming they denied it. 13 | * @default undefined 14 | */ 15 | userDecisionTimeout?: number; 16 | /** 17 | * The implementer of the Geolocation API. 18 | * @default navigator.geolocation 19 | */ 20 | geolocationProvider?: Geolocation; 21 | /** 22 | * If set to true, the hook does not query the Geolocation API on mount. You must use the getLocation method yourself. 23 | * @default false 24 | */ 25 | suppressLocationOnMount?: boolean; 26 | /** 27 | * If set to true, the hook watches for position changes periodically. 28 | * @default false 29 | */ 30 | watchPosition?: boolean; 31 | /** 32 | * Allows to set the default value of isGeolocationEnabled. 33 | * @default true 34 | */ 35 | isOptimisticGeolocationEnabled?: boolean; 36 | /** 37 | * If set to true, the hook watches for location permission changes. 38 | * @default false 39 | */ 40 | watchLocationPermissionChange?: boolean; 41 | /** 42 | * Callback to call when geolocation API invocation fails. Called with undefined when the user decision times out. 43 | */ 44 | onError?: (positionError?: GeolocationPositionError) => void; 45 | /** 46 | * Callback to call when geolocation API invocation succeeds. 47 | */ 48 | onSuccess?: (position: GeolocationPosition) => void; 49 | } 50 | 51 | /** 52 | * Result of the hook. 53 | */ 54 | export interface GeolocatedResult { 55 | /** 56 | * The Geolocation API's coords object containing latitude, longitude, and accuracy and also optionally containing altitude, altitudeAccuracy, heading and speed. 57 | */ 58 | coords: GeolocationCoordinates | undefined; 59 | /** 60 | * The Geolocation API's timestamp value representing the time at which the location was retrieved. 61 | */ 62 | timestamp: EpochTimeStamp | undefined; 63 | /** 64 | * Flag indicating that the browser supports the Geolocation API. 65 | */ 66 | isGeolocationAvailable: boolean; 67 | /** 68 | * Flag indicating that the user has allowed the use of the Geolocation API. It optimistically presumes they did until they either explicitly deny it or userDecisionTimeout (if set) has elapsed and they haven't allowed it yet. 69 | */ 70 | isGeolocationEnabled: boolean; 71 | /** 72 | * The Geolocation API's PositionError object resulting from an error occurring in the API call. 73 | */ 74 | positionError: GeolocationPositionError | undefined; 75 | /** 76 | * Callback you can use to manually trigger the position query. 77 | */ 78 | getPosition: () => void; 79 | } 80 | 81 | /** 82 | * Hook abstracting away the interaction with the Geolocation API. 83 | * @param config - the configuration to use 84 | */ 85 | export function useGeolocated(config: GeolocatedConfig = {}): GeolocatedResult { 86 | const { 87 | positionOptions = { 88 | enableHighAccuracy: true, 89 | maximumAge: 0, 90 | timeout: Infinity, 91 | }, 92 | isOptimisticGeolocationEnabled = true, 93 | userDecisionTimeout = undefined, 94 | suppressLocationOnMount = false, 95 | watchPosition = false, 96 | geolocationProvider = typeof navigator !== "undefined" 97 | ? navigator.geolocation 98 | : undefined, 99 | watchLocationPermissionChange = false, 100 | onError, 101 | onSuccess, 102 | } = config; 103 | 104 | const userDecisionTimeoutId = useRef(0); 105 | const isCurrentlyMounted = useRef(true); 106 | const watchId = useRef(0); 107 | 108 | const [isGeolocationEnabled, setIsGeolocationEnabled] = useState( 109 | isOptimisticGeolocationEnabled, 110 | ); 111 | 112 | const [coords, setCoords] = useState(); 113 | const [timestamp, setTimestamp] = useState(); 114 | const [positionError, setPositionError] = useState< 115 | GeolocationPositionError | undefined 116 | >(); 117 | const [permissionState, setPermissionState] = useState< 118 | PermissionState | undefined 119 | >(); 120 | 121 | const cancelUserDecisionTimeout = useCallback(() => { 122 | if (userDecisionTimeoutId.current) { 123 | window.clearTimeout(userDecisionTimeoutId.current); 124 | } 125 | }, []); 126 | 127 | const handlePositionError = useCallback( 128 | (error?: GeolocationPositionError) => { 129 | cancelUserDecisionTimeout(); 130 | if (isCurrentlyMounted.current) { 131 | setCoords(() => undefined); 132 | setIsGeolocationEnabled(false); 133 | setPositionError(error); 134 | } 135 | onError?.(error); 136 | }, 137 | [onError, cancelUserDecisionTimeout], 138 | ); 139 | 140 | const handlePositionSuccess = useCallback( 141 | (position: GeolocationPosition) => { 142 | cancelUserDecisionTimeout(); 143 | if (isCurrentlyMounted.current) { 144 | setCoords(position.coords); 145 | setTimestamp(position.timestamp); 146 | setIsGeolocationEnabled(true); 147 | setPositionError(() => undefined); 148 | } 149 | onSuccess?.(position); 150 | }, 151 | [onSuccess, cancelUserDecisionTimeout], 152 | ); 153 | 154 | const getPosition = useCallback(() => { 155 | if ( 156 | !geolocationProvider?.getCurrentPosition || 157 | // we really want to check if the watchPosition is available 158 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 159 | !geolocationProvider.watchPosition 160 | ) { 161 | throw new Error("The provided geolocation provider is invalid"); 162 | } 163 | 164 | if (userDecisionTimeout) { 165 | userDecisionTimeoutId.current = window.setTimeout(() => { 166 | handlePositionError(); 167 | }, userDecisionTimeout); 168 | } 169 | 170 | if (watchPosition) { 171 | watchId.current = geolocationProvider.watchPosition( 172 | handlePositionSuccess, 173 | handlePositionError, 174 | positionOptions, 175 | ); 176 | } else { 177 | geolocationProvider.getCurrentPosition( 178 | handlePositionSuccess, 179 | handlePositionError, 180 | positionOptions, 181 | ); 182 | } 183 | }, [ 184 | geolocationProvider, 185 | watchPosition, 186 | userDecisionTimeout, 187 | handlePositionError, 188 | handlePositionSuccess, 189 | positionOptions, 190 | ]); 191 | 192 | useEffect(() => { 193 | let permission: PermissionStatus | undefined = undefined; 194 | 195 | if ( 196 | watchLocationPermissionChange && 197 | geolocationProvider && 198 | "permissions" in navigator 199 | ) { 200 | navigator.permissions 201 | .query({ name: "geolocation" }) 202 | .then((result) => { 203 | permission = result; 204 | permission.onchange = () => { 205 | if (permission) { 206 | setPermissionState(permission.state); 207 | } 208 | }; 209 | }) 210 | .catch((e: unknown) => { 211 | console.error("Error updating the permissions", e); 212 | }); 213 | } 214 | 215 | return () => { 216 | if (permission) { 217 | permission.onchange = null; 218 | } 219 | }; 220 | }, []); // eslint-disable-line react-hooks/exhaustive-deps 221 | 222 | useEffect(() => { 223 | if (!suppressLocationOnMount) { 224 | getPosition(); 225 | } 226 | 227 | return () => { 228 | cancelUserDecisionTimeout(); 229 | if (watchPosition && watchId.current) { 230 | geolocationProvider?.clearWatch(watchId.current); 231 | } 232 | }; 233 | }, [permissionState]); // eslint-disable-line react-hooks/exhaustive-deps 234 | 235 | return { 236 | getPosition, 237 | coords, 238 | timestamp, 239 | isGeolocationEnabled, 240 | isGeolocationAvailable: Boolean(geolocationProvider), 241 | positionError, 242 | }; 243 | } 244 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./demo/**/*.{html,js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /tests/geolocated.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act, render } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | 5 | import { useGeolocated, GeolocatedConfig } from "../src"; 6 | 7 | const Simple = (props: { config: GeolocatedConfig; message?: string }) => { 8 | const { message = "Location: ", config } = props; 9 | const { coords, isGeolocationEnabled } = useGeolocated(config); 10 | 11 | if (isGeolocationEnabled) { 12 | return coords ? ( 13 |
14 | {message} 15 | {coords?.latitude}, {coords?.longitude} 16 |
17 | ) : ( 18 |
Getting geolocation
19 | ); 20 | } else { 21 | return
Geolocation NOT enabled
; 22 | } 23 | }; 24 | 25 | const mockRawCoords: Omit = { 26 | latitude: 50, 27 | longitude: 20, 28 | accuracy: 0.5, 29 | altitude: 200, 30 | altitudeAccuracy: 10, 31 | heading: 0, 32 | speed: 0, 33 | }; 34 | 35 | const mockPosition: GeolocationPosition = { 36 | coords: { 37 | ...mockRawCoords, 38 | toJSON() { 39 | return mockRawCoords; 40 | }, 41 | }, 42 | timestamp: 1234, 43 | toJSON() { 44 | return { 45 | coords: mockRawCoords, 46 | timestamp: 1234, 47 | }; 48 | }, 49 | }; 50 | 51 | const mockSuccessfulGeolocationProvider: Geolocation = { 52 | getCurrentPosition(onSuccess: PositionCallback) { 53 | return onSuccess(mockPosition); 54 | }, 55 | watchPosition(onSuccess: PositionCallback) { 56 | onSuccess(mockPosition); 57 | return 42; 58 | }, 59 | clearWatch() { 60 | return; 61 | }, 62 | }; 63 | 64 | const mockNoopGeolocationProvider = { 65 | getCurrentPosition() { 66 | return; 67 | }, 68 | watchPosition() { 69 | return 42; 70 | }, 71 | clearWatch() { 72 | return; 73 | }, 74 | }; 75 | 76 | jest.useFakeTimers(); 77 | 78 | describe("Geolocated", () => { 79 | beforeAll(() => { 80 | jest.useFakeTimers(); 81 | }); 82 | 83 | afterAll(() => { 84 | jest.useRealTimers(); 85 | }); 86 | 87 | it("should inject the location", async () => { 88 | const config = { 89 | geolocationProvider: mockSuccessfulGeolocationProvider, 90 | }; 91 | 92 | const { findByText } = render(); 93 | expect(await findByText("Location: 50, 20")).toBeInTheDocument(); 94 | }); 95 | 96 | it("should throw on invalid geolocation provider", () => { 97 | const config = { 98 | geolocationProvider: {} as Geolocation, 99 | }; 100 | 101 | expect(() => render()).toThrow( 102 | new Error("The provided geolocation provider is invalid"), 103 | ); 104 | }); 105 | 106 | it("should timeout if user decision timeout is specified", async () => { 107 | const config = { 108 | userDecisionTimeout: 100, 109 | geolocationProvider: mockNoopGeolocationProvider, 110 | }; 111 | 112 | const { findByText } = render(); 113 | 114 | expect(await findByText("Getting geolocation")).toBeInTheDocument(); 115 | 116 | act(() => { 117 | jest.advanceTimersByTime(100); 118 | }); 119 | 120 | expect(await findByText("Geolocation NOT enabled")).toBeInTheDocument(); 121 | }); 122 | 123 | it("should cancel user decision timeout on success", async () => { 124 | const config = { 125 | userDecisionTimeout: 100, 126 | geolocationProvider: mockSuccessfulGeolocationProvider, 127 | }; 128 | 129 | const { findByText } = render(); 130 | 131 | act(() => { 132 | jest.advanceTimersByTime(200); 133 | }); 134 | 135 | expect(await findByText("Location: 50, 20")).toBeInTheDocument(); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "esModuleInterop": true, 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "declaration": true, 8 | "outDir": "./dist-modules", 9 | "isolatedModules": true, 10 | "target": "ES6", 11 | "module": "CommonJS", 12 | "lib": ["DOM"] 13 | }, 14 | "files": ["./src/index.ts"] 15 | } 16 | --------------------------------------------------------------------------------