├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── basic.yml │ └── deploy.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.json ├── example ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ └── vite.svg ├── src │ ├── App.tsx │ ├── assets │ │ ├── Github.tsx │ │ ├── Npm.tsx │ │ ├── Sync.tsx │ │ └── Unsync.tsx │ ├── components │ │ ├── Actions.tsx │ │ ├── Code.tsx │ │ └── Spinner.tsx │ ├── hooks │ │ └── useColorScheme.ts │ ├── index.css │ ├── main.tsx │ ├── stores │ │ └── useCountStore.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── jest.config.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.ts ├── shared.ts ├── useBroadcast.test.ts └── useBroadcast.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/basic.yml: -------------------------------------------------------------------------------- 1 | name: use-broadcast-ts basic workflow 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Creating Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: "18" 16 | 17 | - name: Caching the modules 18 | uses: actions/cache@v3 19 | env: 20 | cache-name: node-modules 21 | with: 22 | path: ~/.npm 23 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-build-${{ env.cache-name }}- 26 | ${{ runner.os }}-build- 27 | ${{ runner.os }}- 28 | 29 | - name: Installing dependencies 30 | run: npm install 31 | 32 | - name: Running the tests 33 | run: npm run test -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: use-broadcast-ts deploy on pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'example/**' 9 | 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | defaults: 19 | run: 20 | working-directory: example 21 | 22 | steps: 23 | - name: 🛎️ Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: 🔧 Creating Node 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: '18' 30 | 31 | - name: ⚡Installing dependencies 32 | run: npm install 33 | 34 | - name: 📦 Build 35 | run: npm run build 36 | 37 | - name: Upload Artifacts 🔼 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: site 41 | path: example/dist 42 | 43 | deploy: 44 | concurrency: ci-${{ github.ref }} 45 | needs: [build] 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - name: 🛎️ Checkout 50 | uses: actions/checkout@v3 51 | 52 | - name: 🔽 Download Artifacts 53 | uses: actions/download-artifact@v1 54 | with: 55 | name: site 56 | 57 | - name: 🚀 Deploy 58 | uses: JamesIves/github-pages-deploy-action@v4 59 | with: 60 | folder: 'site' 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | node_modules/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | build/ 3 | node_modules/**/* 4 | src/*.test.ts 5 | balel.config.json 6 | jest.config.json 7 | .prettierrc 8 | .eslintrc 9 | .gitattributes 10 | example/ 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Romain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-broadcast-ts 2 | 3 | [![Version](https://img.shields.io/npm/v/use-broadcast-ts?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/use-broadcast-ts) 4 | [![Build Size](https://img.shields.io/bundlephobia/minzip/use-broadcast-ts?label=bundle%20size&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/result?p=use-broadcast-ts) 5 | ![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/Romainlg29/use-broadcast/basic.yml?branch=main&colorA=000000&colorB=000000) 6 | ![GitHub](https://img.shields.io/github/license/Romainlg29/use-broadcast?&colorA=000000&colorB=000000) 7 | 8 | Use the Broadcast Channel API in React easily with `hooks` or `Zustand`, and Typescript! 9 | 10 | ```bash 11 | npm install use-broadcast-ts 12 | ``` 13 | 14 | This package allows you to use the Broadcast API with a simple hook or by using Zustand v4/v5. 15 | 16 | Checkout the [demo](https://romainlg29.github.io/use-broadcast/)! 17 | 18 | ## Usage with Zustand 19 | 20 | ```jsx 21 | // useStore.ts 22 | import { create } from 'zustand'; 23 | import { shared } from 'use-broadcast-ts'; 24 | 25 | type MyStore = { 26 | count: number; 27 | set: (n: number) => void; 28 | }; 29 | 30 | const useStore = create( 31 | shared( 32 | (set) => ({ 33 | count: 0, 34 | set: (n) => set({ count: n }) 35 | }), 36 | { name: 'my-channel' } 37 | ) 38 | ); 39 | 40 | // MyComponent.tsx 41 | import { FC } from 'react'; 42 | import { useShallow } from 'zustand/shallow' 43 | 44 | const MyComponent : FC = () => { 45 | 46 | const count = useStore((s) => s.count); 47 | const set = useStore((s) => s.set); 48 | 49 | return ( 50 |

51 |

Count: {count}

52 | 17 | 18 | 19 | 20 |
21 |
22 | 32 | 42 |
43 |
44 | 45 |
46 | 47 | 50 | 51 |
52 | } 53 | > 54 |
55 | 56 |
57 | 58 | 59 |
60 | }> 61 | 62 | 63 |
64 | 65 | ); 66 | }; 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /example/src/assets/Github.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from '../hooks/useColorScheme'; 2 | 3 | const Github = () => { 4 | const isDark = useColorScheme(); 5 | 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Github; -------------------------------------------------------------------------------- /example/src/assets/Npm.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from '../hooks/useColorScheme'; 2 | 3 | const Npm = () => { 4 | const isDark = useColorScheme(); 5 | 6 | return ( 7 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default Npm; 22 | -------------------------------------------------------------------------------- /example/src/assets/Sync.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "../hooks/useColorScheme"; 2 | 3 | const SyncIcon = () => { 4 | 5 | const isDark = useColorScheme(); 6 | 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default SyncIcon; -------------------------------------------------------------------------------- /example/src/assets/Unsync.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "../hooks/useColorScheme"; 2 | 3 | const UnsyncIcon = () => { 4 | 5 | const isDark = useColorScheme(); 6 | 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default UnsyncIcon; 15 | -------------------------------------------------------------------------------- /example/src/components/Actions.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from './Spinner'; 2 | 3 | import { FC, lazy, Suspense } from 'react'; 4 | import { useCountStore } from '../stores/useCountStore'; 5 | 6 | const SyncIcon = lazy(() => import('../assets/Sync')); 7 | const UnsyncIcon = lazy(() => import('../assets/Unsync')); 8 | 9 | const Actions: FC = () => { 10 | const { mode, increment, decrement, setMode } = useCountStore((s) => ({ 11 | mode: s.mode, 12 | increment: s.increment, 13 | decrement: s.decrement, 14 | setMode: s.setMode, 15 | })); 16 | 17 | return ( 18 |
19 | 23 | 24 | 28 | 29 |
30 | 36 | 44 |
45 | 46 | 49 | 50 | {mode === 'Not Sync' && ( 51 |
52 |
53 | This sync mode won't work in this example. 54 |
55 |
56 | )} 57 |
58 | ); 59 | }; 60 | 61 | export default Actions; 62 | -------------------------------------------------------------------------------- /example/src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { useCountStore } from '../stores/useCountStore'; 3 | 4 | const tab = ' '; 5 | 6 | const Code: FC = () => { 7 | const { count, mode } = useCountStore((s) => ({ count: s.count, mode: s.mode })); 8 | 9 | return ( 10 | <> 11 |
 12 | 				
 13 | 					import { create{' '}
 14 | 					} from 'zustand'
 15 | 					;
 16 | 				
 17 | 			
18 |
 19 | 				
 20 | 					import { shared{' '}
 21 | 					} from{' '}
 22 | 					'use-broadcast-ts'
 23 | 					;
 24 | 				
 25 | 			
26 |

 27 | 			
 28 | 				
 29 | 					type CountStore = {
 30 | 				
 31 | 			
32 |
 33 | 				
 34 | 					{tab}
 35 | 					count: number
 36 | 					;
 37 | 				
 38 | 			
39 |
 40 | 				
 41 | 					{tab}increment: () =>{' '}
 42 | 					void
 43 | 					;
 44 | 				
 45 | 			
46 |
 47 | 				
 48 | 					{tab}decrement: () =>{' '}
 49 | 					void
 50 | 					;
 51 | 				
 52 | 			
53 |
 54 | 				};
 55 | 			
56 |

 57 | 			
 58 | 				
 59 | 					export const{' '}
 60 | 					useCountStore = create{'<'}
 61 | 					CountStore
 62 | 					{'>('}{' '}
 63 | 				
 64 | 			
65 |
 66 | 				
 67 | 					{tab}
 68 | 					shared
 69 | 					{'('}
 70 | 				
 71 | 			
72 |
 73 | 				
 74 | 					{tab}
 75 | 					{tab}
 76 | 					{'('}
 77 | 					set
 78 | 					, 
 79 | 					get
 80 | 					{') => ('}
 81 | 					{'{'}
 82 | 				
 83 | 			
84 |
 85 | 				
 86 | 					{tab}
 87 | 					{tab}
 88 | 					{tab}
 89 | 					count: {count},
 90 | 				
 91 | 			
92 |
 93 | 				
 94 | 					{tab}
 95 | 					{tab}
 96 | 					{tab}
 97 | 					increment
 98 | 					: (){' '}
 99 | 					=> set
100 | 					{'('}
101 | 					{'{'} count:{' '}
102 | 					get()
103 | 					.
104 | 					count
105 | 					 + 
106 | 					1 {'}'}
107 | 					{')'},
108 | 				
109 | 			
110 |
111 | 				
112 | 					{tab}
113 | 					{tab}
114 | 					{tab}
115 | 					decrement
116 | 					: (){' '}
117 | 					=> set
118 | 					{'('}
119 | 					{'{'} count:{' '}
120 | 					get()
121 | 					.
122 | 					count
123 | 					 - 
124 | 					1 {'}'}
125 | 					{')'},
126 | 				
127 | 			
128 |
129 | 				
130 | 					{tab}
131 | 					{tab}
132 | 					{'}'}
133 | 					{')'},
134 | 				
135 | 			
136 |
137 | 				
138 | 					{tab}
139 | 					{tab}
140 | 					{'{'} name:{' '}
141 | 					'count-store'
142 | 					{mode === 'Not Sync' ? (
143 | 						<>
144 | 							, unsync: 
145 | 							true
146 | 						
147 | 					) : (
148 | 						''
149 | 					)}{' '}
150 | 					{'}'}
151 | 				
152 | 			
153 |
154 | 				
155 | 					{tab}
156 | 					{')'}
157 | 				
158 | 			
159 |
160 | 				
161 | 					{')'}
162 | 					{';'}
163 | 				
164 | 			
165 | 166 | ); 167 | }; 168 | 169 | export default Code; 170 | -------------------------------------------------------------------------------- /example/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | const Spinner: FC = () => ; 4 | 5 | export default Spinner; 6 | -------------------------------------------------------------------------------- /example/src/hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useColorScheme = () => { 4 | const theme = (() => window.matchMedia('(prefers-color-scheme: dark)').matches)(); 5 | 6 | const [isDark, setIsDark] = useState(theme); 7 | 8 | useEffect(() => { 9 | const listener = window.matchMedia('(prefers-color-scheme: dark)'); 10 | 11 | listener.addEventListener('change', (e) => setIsDark(e.matches)); 12 | 13 | return () => listener.removeEventListener('change', (e) => setIsDark(e.matches)); 14 | }, []); 15 | 16 | return isDark; 17 | }; 18 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /example/src/stores/useCountStore.ts: -------------------------------------------------------------------------------- 1 | import { shared } from 'use-broadcast-ts'; 2 | import { createWithEqualityFn as create } from 'zustand/traditional'; 3 | 4 | type CountStore = { 5 | count: number; 6 | increment: () => void; 7 | decrement: () => void; 8 | 9 | mode: 'Sync' | 'Not Sync'; 10 | setMode: (mode: 'Sync' | 'Not Sync') => void; 11 | }; 12 | 13 | export const useCountStore = create()( 14 | shared( 15 | (set, get) => ({ 16 | count: 0, 17 | increment: () => set((s) => ({ count: s.count + 1 })), 18 | decrement: () => set({ count: get().count - 1 }), 19 | 20 | mode: 'Sync', 21 | setMode: (mode) => set({ mode }), 22 | }), 23 | { name: 'my-store' } 24 | ) 25 | ); 26 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | animation: { 7 | in: 'fadeIn 0.5s ease-in-out', 8 | }, 9 | keyframes: { 10 | fadeIn: { 11 | '0%': { opacity: 0, scale: '90%' }, 12 | '100%': { opacity: 1, scale: '100%' }, 13 | }, 14 | }, 15 | }, 16 | }, 17 | plugins: [require('daisyui')], 18 | }; 19 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /example/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: '' 8 | }) 9 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "testEnvironment": "jsdom" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-broadcast-ts", 3 | "version": "2.0.0", 4 | "description": "Use the Broadcast Channel API in React easily with hooks or Zustand, and Typescript!", 5 | "type": "module", 6 | "types": "./dist/index.d.ts", 7 | "main": "./dist/index.js", 8 | "module": "./dist/index.mjs", 9 | "exports": { 10 | "types": "./dist/index.d.ts", 11 | "require": "./dist/index.js", 12 | "import": "./dist/index.mjs" 13 | }, 14 | "sideEffects": false, 15 | "scripts": { 16 | "build": "rollup -c --bundleConfigAsCjs", 17 | "postbuild": "tsc --emitDeclarationOnly", 18 | "prepublishOnly": "npm run build", 19 | "test": "jest" 20 | }, 21 | "husky": { 22 | "hooks": { 23 | "pre-commit": "lint-staged" 24 | } 25 | }, 26 | "lint-staged": { 27 | "*.{js,jsx,ts,tsx}": [ 28 | "eslint --fix" 29 | ] 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/Romainlg29/use-broadcast.git" 34 | }, 35 | "keywords": [ 36 | "web", 37 | "api", 38 | "broadcast", 39 | "channel", 40 | "broadcast-channel", 41 | "hooks", 42 | "react", 43 | "react 18", 44 | "zustand", 45 | "middleware", 46 | "state" 47 | ], 48 | "author": "Romain Le Gall", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/Romainlg29/use-broadcast/issues" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "7.21.4", 55 | "@babel/plugin-proposal-class-properties": "^7.18.6", 56 | "@babel/plugin-transform-modules-commonjs": "7.21.2", 57 | "@babel/plugin-transform-parameters": "7.21.3", 58 | "@babel/plugin-transform-runtime": "7.21.4", 59 | "@babel/plugin-transform-template-literals": "7.18.9", 60 | "@babel/preset-env": "^7.21.4", 61 | "@babel/preset-react": "^7.18.6", 62 | "@babel/preset-typescript": "^7.21.4", 63 | "@rollup/plugin-babel": "^6.0.3", 64 | "@rollup/plugin-node-resolve": "^15.0.2", 65 | "@testing-library/jest-dom": "^5.16.5", 66 | "@testing-library/react": "^14.0.0", 67 | "@types/jest": "^29.5.1", 68 | "@types/node": "^18.15.13", 69 | "@types/react": "^18.0.37", 70 | "@types/react-dom": "^18.0.11", 71 | "@types/react-test-renderer": "^18.0.0", 72 | "@typescript-eslint/eslint-plugin": "^5.59.0", 73 | "@typescript-eslint/parser": "^5.59.0", 74 | "babel-jest": "^29.5.0", 75 | "babel-loader": "^9.1.2", 76 | "babel-plugin-dynamic-import-node": "^2.3.3", 77 | "husky": "^8.0.3", 78 | "jest": "^29.5.0", 79 | "jest-environment-jsdom": "^29.5.0", 80 | "lint-staged": "^13.2.1", 81 | "prettier": "^2.8.7", 82 | "react": "^18.2.0", 83 | "react-dom": "^18.2.0", 84 | "react-test-renderer": "^18.2.0", 85 | "rollup": "^3.20.7", 86 | "typescript": "^5.0.4" 87 | }, 88 | "homepage": "https://github.com/Romainlg29/use-broadcast", 89 | "optionalDependencies": { 90 | "zustand": "^4.0.0 || ^5.0.0", 91 | "react": ">=18.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import babel from '@rollup/plugin-babel' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | 5 | const root = process.platform === 'win32' ? path.resolve('/') : '/' 6 | const external = (id) => !id.startsWith('.') && !id.startsWith(root) 7 | const extensions = ['.js', '.jsx', '.ts', '.tsx', '.json'] 8 | 9 | const getBabelOptions = ({ useESModules }) => ({ 10 | babelrc: false, 11 | extensions, 12 | exclude: '**/node_modules/**', 13 | babelHelpers: 'runtime', 14 | presets: [ 15 | [ 16 | '@babel/preset-env', 17 | { 18 | include: [ 19 | '@babel/plugin-proposal-optional-chaining', 20 | '@babel/plugin-proposal-nullish-coalescing-operator', 21 | '@babel/plugin-proposal-numeric-separator', 22 | '@babel/plugin-proposal-logical-assignment-operators', 23 | ], 24 | bugfixes: true, 25 | loose: true, 26 | modules: false, 27 | targets: '> 1%, not dead, not ie 11, not op_mini all', 28 | }, 29 | ], 30 | '@babel/preset-react', 31 | '@babel/preset-typescript', 32 | ], 33 | plugins: [['@babel/transform-runtime', { regenerator: false, useESModules }]], 34 | }) 35 | 36 | export default [ 37 | { 38 | input: `./src/index.ts`, 39 | output: { file: `dist/index.mjs`, format: 'esm' }, 40 | external, 41 | plugins: [babel(getBabelOptions({ useESModules: true })), resolve({ extensions })], 42 | }, 43 | { 44 | input: `./src/index.ts`, 45 | output: { file: `dist/index.js`, format: 'cjs' }, 46 | external, 47 | plugins: [babel(getBabelOptions({ useESModules: false })), resolve({ extensions })], 48 | }, 49 | ] -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useBroadcast'; 2 | export * from './shared'; -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import { StateCreator, StoreMutatorIdentifier } from 'zustand'; 2 | 3 | export type SharedOptions = { 4 | /** 5 | * The name of the broadcast channel 6 | * It must be unique 7 | */ 8 | name?: string; 9 | 10 | /** 11 | * Main timeout 12 | * If the main tab / window doesn't respond in this time, this tab / window will become the main 13 | * @default 100 ms 14 | */ 15 | mainTimeout?: number; 16 | 17 | /** 18 | * If true, the store will only synchronize once with the main tab. After that, the store will be unsynchronized. 19 | * @default false 20 | */ 21 | unsync?: boolean; 22 | 23 | /** 24 | * If true, will not serialize with JSON.parse(JSON.stringify(state)) the state before sending it. 25 | * This results in a performance boost, but it is on the user to ensure there are no unsupported types in their state. 26 | * @default false 27 | */ 28 | skipSerialization?: boolean; 29 | 30 | /** 31 | * Custom function to parse the state before sending it to the other tabs 32 | * @param state The state 33 | * @returns The parsed state 34 | */ 35 | partialize?: (state: T) => Partial; 36 | 37 | /** 38 | * Custom function to merge the state after receiving it from the other tabs 39 | * @param state The current state 40 | * @param receivedState The state received from the other tab 41 | * @returns The restored state 42 | */ 43 | merge?: (state: T, receivedState: Partial) => T; 44 | 45 | /** 46 | * Callback when this tab / window becomes the main tab / window 47 | * Triggered only in the main tab / window 48 | */ 49 | onBecomeMain?: (id: number) => void; 50 | 51 | /** 52 | * Callback when a new tab is opened / closed 53 | * Triggered only in the main tab / window 54 | */ 55 | onTabsChange?: (ids: number[]) => void; 56 | }; 57 | 58 | /** 59 | * The Shared type 60 | */ 61 | export type Shared = < 62 | T extends object, 63 | Mps extends [StoreMutatorIdentifier, unknown][] = [], 64 | Mcs extends [StoreMutatorIdentifier, unknown][] = [] 65 | >( 66 | f: StateCreator, 67 | options?: SharedOptions 68 | ) => StateCreator; 69 | 70 | /** 71 | * Type implementation of the Shared function 72 | */ 73 | type SharedImpl = (f: StateCreator, options?: SharedOptions) => StateCreator; 74 | 75 | /** 76 | * Shared implementation 77 | * @param f Zustand state creator 78 | * @param options The options 79 | */ 80 | const sharedImpl: SharedImpl = (f, options) => (set, get, store) => { 81 | /** 82 | * The broadcast channel is not supported in SSR 83 | */ 84 | if ( 85 | typeof window === 'undefined' && 86 | // @ts-expect-error WorkerGlobalScope is not defined in the types 87 | !(typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) 88 | ) { 89 | console.warn('BroadcastChannel is not supported in this environment. The store will not be shared.'); 90 | return f(set, get, store); 91 | } 92 | 93 | /** 94 | * If BroadcastChannel is not supported, return the basic store 95 | */ 96 | if (typeof BroadcastChannel === 'undefined') { 97 | console.warn('BroadcastChannel is not supported in this browser. The store will not be shared.'); 98 | return f(set, get, store); 99 | } 100 | 101 | /** 102 | * Types 103 | */ 104 | type T = ReturnType; 105 | 106 | type Item = { [key: string]: unknown }; 107 | type Message = 108 | | { 109 | action: 'sync'; 110 | } 111 | | { 112 | action: 'change'; 113 | state: Item; 114 | } 115 | | { 116 | action: 'add_new_tab'; 117 | id: number; 118 | } 119 | | { 120 | action: 'close'; 121 | id: number; 122 | } 123 | | { 124 | action: 'change_main'; 125 | id: number; 126 | tabs: number[]; 127 | }; 128 | 129 | /** 130 | * Is the store synced with the other tabs 131 | */ 132 | let isSynced = get() !== undefined; 133 | 134 | /** 135 | * Is this tab / window the main tab / window 136 | * When a new tab / window is opened, it will be synced with the main 137 | */ 138 | let isMain = false; 139 | 140 | /** 141 | * The broadcast channel name 142 | */ 143 | const name = options?.name ?? f.toString(); 144 | 145 | /** 146 | * The id of the tab / window 147 | */ 148 | let id = 0; 149 | 150 | /** 151 | * Store a list of all the tabs / windows 152 | * Only for the main tab / window 153 | */ 154 | const tabs: number[] = [0]; 155 | 156 | /** 157 | * Create the broadcast channel 158 | */ 159 | const channel = new BroadcastChannel(name); 160 | 161 | const sendChangeToOtherTabs = () => { 162 | let state: Item = get() as Item; 163 | 164 | /** 165 | * If the partialize function is provided, use it to parse the state 166 | */ 167 | if (options?.partialize) { 168 | // Partialize the state 169 | state = options.partialize(state as T); 170 | } 171 | 172 | /** 173 | * If the user did not specify that serialization should be skipped, remove unsupported types 174 | */ 175 | if (!options?.skipSerialization) { 176 | // Remove unserializable types (functions, Symbols, etc.) from the state. 177 | state = JSON.parse(JSON.stringify(state)); 178 | } 179 | 180 | /** 181 | * Send the states to all the other tabs 182 | */ 183 | channel.postMessage({ action: 'change', state } as Message); 184 | }; 185 | 186 | /** 187 | * Handle the Zustand set function 188 | * Trigger a postMessage to all the other tabs 189 | */ 190 | const onSet: typeof set = (...args) => { 191 | /** 192 | * Update the states 193 | */ 194 | set(...(args as Parameters)); 195 | 196 | /** 197 | * If the stores should not be synced, return. 198 | */ 199 | if (options?.unsync) { 200 | return; 201 | } 202 | 203 | sendChangeToOtherTabs(); 204 | }; 205 | 206 | /** 207 | * Subscribe to the broadcast channel 208 | */ 209 | channel.onmessage = (e) => { 210 | if ((e.data as Message).action === 'sync') { 211 | /** 212 | * If this tab / window is not the main, return 213 | */ 214 | if (!isMain) { 215 | return; 216 | } 217 | 218 | sendChangeToOtherTabs(); 219 | 220 | /** 221 | * Set the new tab / window id 222 | */ 223 | const new_id = tabs[tabs.length - 1]! + 1; 224 | tabs.push(new_id); 225 | 226 | options?.onTabsChange?.(tabs); 227 | 228 | channel.postMessage({ action: 'add_new_tab', id: new_id } as Message); 229 | 230 | return; 231 | } 232 | 233 | /** 234 | * Set an id for the tab / window if it doesn't have one 235 | */ 236 | if ((e.data as Message).action === 'add_new_tab' && !isMain && id === 0) { 237 | id = e.data.id; 238 | return; 239 | } 240 | 241 | /** 242 | * On receiving a new state, update the state 243 | */ 244 | if ((e.data as Message).action === 'change') { 245 | /** 246 | * Update the state 247 | */ 248 | set((state) => (options?.merge ? options.merge(state, e.data.state as Partial) : e.data.state)); 249 | 250 | /** 251 | * Set the synced attribute 252 | */ 253 | isSynced = true; 254 | } 255 | 256 | /** 257 | * On receiving a close message, remove the tab / window id from the list 258 | */ 259 | if ((e.data as Message).action === 'close') { 260 | if (!isMain) { 261 | return; 262 | } 263 | 264 | const index = tabs.indexOf(e.data.id); 265 | if (index !== -1) { 266 | tabs.splice(index, 1); 267 | 268 | options?.onTabsChange?.(tabs); 269 | } 270 | } 271 | 272 | /** 273 | * On receiving a change_main message, change the main tab / window 274 | */ 275 | if ((e.data as Message).action === 'change_main') { 276 | if (e.data.id === id) { 277 | isMain = true; 278 | tabs.splice(0, tabs.length, ...e.data.tabs); 279 | 280 | options?.onBecomeMain?.(id); 281 | } 282 | } 283 | }; 284 | 285 | /** 286 | * Synchronize with the main tab 287 | */ 288 | const synchronize = (): void => { 289 | channel.postMessage({ action: 'sync' } as Message); 290 | 291 | /** 292 | * If isSynced is false after 100ms, this tab is the main tab 293 | */ 294 | setTimeout(() => { 295 | if (!isSynced) { 296 | isMain = true; 297 | isSynced = true; 298 | 299 | options?.onBecomeMain?.(id); 300 | } 301 | }, options?.mainTimeout ?? 100); 302 | }; 303 | 304 | /** 305 | * Handle case when the tab / window is closed 306 | */ 307 | const onClose = (): void => { 308 | /** 309 | * For some reason, the channel can be closed abruptly, when redirecting for example 310 | * So we need to wrap this in a try catch block 311 | */ 312 | try { 313 | channel.postMessage({ action: 'close', id } as Message); 314 | 315 | /** 316 | * If we're closing the main, make the second the new main 317 | */ 318 | if (isMain) { 319 | /** 320 | * If there is only one tab left, close the channel and return 321 | */ 322 | if (tabs.length === 1) { 323 | /** 324 | * Clean up 325 | */ 326 | channel.close(); 327 | return; 328 | } 329 | 330 | const remaining_tabs = tabs.filter((tab) => tab !== id); 331 | channel.postMessage({ action: 'change_main', id: remaining_tabs[0], tabs: remaining_tabs } as Message); 332 | 333 | return; 334 | } 335 | } catch (e) {} 336 | }; 337 | 338 | /** 339 | * Add close event listener 340 | */ 341 | if (typeof window !== 'undefined') { 342 | window.addEventListener('beforeunload', onClose); 343 | } 344 | 345 | /** 346 | * Synchronize with the main tab 347 | */ 348 | if (!isSynced) { 349 | synchronize(); 350 | } 351 | 352 | /** 353 | * Modify and return the Zustand store 354 | */ 355 | store.setState = onSet; 356 | 357 | return f(onSet, get, store); 358 | }; 359 | 360 | /** 361 | * Shared middleware 362 | * 363 | * @example 364 | * import { create } from 'zustand'; 365 | * import { shared } from 'use-broadcast-ts'; 366 | * 367 | * const useStore = create( 368 | * shared( 369 | * (set) => ({ count: 0 }), 370 | * { name: 'my-store' } 371 | * ) 372 | * ); 373 | */ 374 | export const shared = sharedImpl as Shared; 375 | -------------------------------------------------------------------------------- /src/useBroadcast.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { useBroadcast } from './useBroadcast'; 3 | 4 | describe('useBroadcast', () => { 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | }); 8 | 9 | it('should return the initial value.', () => { 10 | const { result } = renderHook(() => useBroadcast('my-channel', 'hello')); 11 | 12 | expect(result.current.state).toBe('hello'); 13 | }); 14 | 15 | it('should return undefined if no initial value is provided.', () => { 16 | const { result } = renderHook(() => useBroadcast('my-channel')); 17 | 18 | expect(result.current.state).toBeUndefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/useBroadcast.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | /** 4 | * Our hook will return an object with three properties: 5 | * - send: a function that will send a message to all other tabs 6 | * - state: the current state of the broadcast 7 | * - subscribe: a function that will subscribe to the broadcast (Only if options.subscribe is true) 8 | */ 9 | export type UseBroadcastReturn = { 10 | send: (val: T) => void; 11 | state: T | undefined; 12 | subscribe: (callback: (e: T) => void) => () => void; 13 | }; 14 | 15 | /** 16 | * The options for the useBroadcast hook 17 | */ 18 | export type UseBroadcastOptions = { 19 | subscribe?: boolean; 20 | }; 21 | 22 | /** 23 | * 24 | * @param name The name of the broadcast channel 25 | * @param val The initial value of the broadcast 26 | * @returns Returns an object with three properties: send, state and subscribe 27 | */ 28 | export const useBroadcast = ( 29 | name: string, 30 | val?: T, 31 | options?: UseBroadcastOptions 32 | ): UseBroadcastReturn => { 33 | /** 34 | * Store the state of the broadcast 35 | */ 36 | const [state, setState] = useState(val); 37 | 38 | /** 39 | * Store the BroadcastChannel instance 40 | */ 41 | const channel = useRef(null); 42 | 43 | /** 44 | * Store the listeners 45 | */ 46 | const listeners = useRef<((e: T) => void)[]>([]); 47 | 48 | /** 49 | * This function send the value to all the other tabs 50 | * @param val The value to send 51 | */ 52 | const send = (val: T) => { 53 | if (!channel.current) { 54 | return; 55 | } 56 | 57 | /** 58 | * Send the value to all the other tabs 59 | */ 60 | channel.current.postMessage(val); 61 | 62 | if (!options?.subscribe) { 63 | setState(val); 64 | } 65 | 66 | /** 67 | * Dispatch the event to the listeners 68 | */ 69 | listeners.current.forEach((listener) => listener(val)); 70 | }; 71 | 72 | /** 73 | * This function subscribe to the broadcast 74 | * @param callback The callback function 75 | * @returns Returns a function that unsubscribe the callback 76 | */ 77 | const subscribe = (callback: (e: T) => void) => { 78 | /** 79 | * Add the callback to the listeners 80 | */ 81 | listeners.current.push(callback); 82 | 83 | /** 84 | * Return a function that unsubscribe the callback 85 | */ 86 | return () => 87 | listeners.current.splice(listeners.current.indexOf(callback), 1); 88 | }; 89 | 90 | useEffect(() => { 91 | /** 92 | * If BroadcastChannel is not supported, we log an error and return 93 | */ 94 | if (typeof window === "undefined") { 95 | console.error("Window is undefined!"); 96 | return; 97 | } 98 | 99 | if (!window.BroadcastChannel) { 100 | console.error("BroadcastChannel is not supported!"); 101 | return; 102 | } 103 | 104 | /** 105 | * If the channel is null, we create a new one 106 | */ 107 | if (!channel.current) { 108 | channel.current = new BroadcastChannel(name); 109 | } 110 | 111 | /** 112 | * Subscribe to the message event 113 | * @param e The message event 114 | */ 115 | channel.current.onmessage = (e) => { 116 | /** 117 | * Update the state 118 | */ 119 | if (!options?.subscribe) { 120 | setState(e.data); 121 | } 122 | 123 | /** 124 | * Dispatch an event to the listeners 125 | */ 126 | listeners.current.forEach((listener) => listener(e.data)); 127 | }; 128 | 129 | /** 130 | * Cleanup 131 | */ 132 | return () => { 133 | if (!channel.current) { 134 | return; 135 | } 136 | 137 | channel.current.close(); 138 | channel.current = null; 139 | }; 140 | }, [name, options]); 141 | 142 | return { send, state, subscribe }; 143 | }; 144 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2018", 5 | "allowSyntheticDefaultImports": true, 6 | "jsx": "react", 7 | "strict": true, 8 | "preserveSymlinks": true, 9 | "moduleResolution": "Node", 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "declarationDir": "dist", 13 | "skipLibCheck": true, 14 | "removeComments": false, 15 | "baseUrl": "." 16 | }, 17 | "include": ["src", "src/useBroadcast.test.ts"] 18 | } 19 | --------------------------------------------------------------------------------