diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 47c926b3..95f5d426 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,7 @@ # These are supported funding model platforms github: [mayank1513] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +polar: mayank1513 patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 78ecd9da..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Mayank - -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: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -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. diff --git a/README.md b/README.md index 30f775aa..082bdb44 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ## Motivation -I have built wonderful libraries utilizing React18 features using Zustand. They worked awesome. However, when I try importing from specific folder for better tree-shaking, the libraries fail. This is because, for each import a separate zustand store is created. This actually increases the package size also. +I've developed fantastic libraries leveraging React18 features using Zustand, and they performed admirably. However, when attempting to import from specific folders for better tree-shaking, the libraries encountered issues. Each import resulted in a separate Zustand store being created, leading to increased package size. -Thus, I decided to create a bare minimum, ultra-light store that creates shared state even while importing components from separate files for better treeshaking. +As a solution, I set out to create a lightweight, bare minimum store that facilitates shared state even when importing components from separate files, optimizing tree-shaking. ## Features @@ -14,109 +14,55 @@ Thus, I decided to create a bare minimum, ultra-light store that creates shared ✅ Unleash the full power of React18 Server components -✅ Works with all build systems/tools/frameworks for React18 +✅ Compatible with all build systems/tools/frameworks for React18 -✅ Doccumented with [Typedoc](https://react18-tools.github.io/react18-global-store) ([Docs](https://react18-tools.github.io/react18-global-store)) +✅ Documented with [Typedoc](https://react18-tools.github.io/react18-global-store) ([Docs](https://react18-tools.github.io/react18-global-store)) -✅ Next.js, Vite and Remix examples +✅ Examples for Next.js, Vite, and Remix -## Install +## Simple Global State Shared Across Multiple Components -> A canonical package with longer name is also published `react18-global-store` - -```bash -$ pnpm add r18gs -``` - -or - -```bash -$ npm install r18gs -``` - -or - -```bash -$ yarn add r18gs -``` - -## Usage - -Use this hook similar to `useState` hook. - -The difference is that you need to pass an unique key - unique across the app to identify -and make this state accessible to all client components. +Utilize this hook similarly to the `useState` hook. However, ensure to pass a unique key, unique across the app, to identify and make this state accessible to all client components. ```tsx const [state, setState] = useRGS("counter", 1); ``` -You can access the same state across all client side components using unique key. +> For detailed instructions, see [Getting Started](./md-docs/1.getting-started.md) -> It is recommended to store your keys in separate file to avoid typos and unnecessary conflicts. +## Using Plugins -### Example +Enhance the functionality of the store by leveraging either the `create` function, `withPlugins` function, or the `useRGSWithPlugins` hook from `r18gs/dist/with-plugins`, enabling features such as storing to local storage, among others. ```tsx -// constants/global-states.ts -export const COUNTER = "counter"; -``` +// store.ts +import { create } from "r18gs/dist/with-plugins"; +import { persist } from "r18gs/dist/plugins"; /** You can create your own plugin or import third-party plugins */ -```tsx -// components/display.tsx -"use client"; - -import useRGS from "r18gs"; -import { COUNTER } from "../constants/global-states"; - -export default function Display() { - const [count] = useRGS(COUNTER); - return ( -
-

Client component 2

- {count} -
- ); -} +export const useMyPersistentCounterStore = create("persistent-counter", 0, [persist()]); ``` +Now, you can utilize `useMyPersistentCounterStore` similarly to `useState` without specifying an initial value. + ```tsx -// components/counter.tsx -"use client"; - -import useRGS from "r18gs"; -import { COUNTER } from "../constants/global-states"; - -export default function Counter() { - const [count, setCount] = useRGS(COUNTER, 0); - return ( -
-

Clinet component 1

- { - setCount(parseInt(e.target.value.trim())); - }} - type="number" - value={count} - /> -
- ); -} +const [persistedCount, setPersistedCount] = useMyPersistentCounterStore(); ``` +> For detailed instructions, see [Leveraging Plugins](./md-docs/2.leveraging-plugins.md) + ## Contributing See [contributing.md](/contributing.md) -### 🤩 Don't forger to star [this repo](https://github.com/mayank1513/react18-global-store)! +### 🤩 Don't forget to star [this repo](https://github.com/mayank1513/react18-global-store)! -Want hands-on course for getting started with Turborepo? Check out [React and Next.js with TypeScript](https://mayank-chaudhari.vercel.app/courses/react-and-next-js-with-typescript) and [The Game of Chess with Next.js, React and TypeScrypt](https://www.udemy.com/course/game-of-chess-with-nextjs-react-and-typescrypt/?referralCode=851A28F10B254A8523FE) +Interested in hands-on courses for getting started with Turborepo? Check out [React and Next.js with TypeScript](https://mayank-chaudhari.vercel.app/courses/react-and-next-js-with-typescript) and [The Game of Chess with Next.js, React and TypeScript](https://www.udemy.com/course/game-of-chess-with-nextjs-react-and-typescript/?referralCode=851A28F10B254A8523FE) ![Repo Stats](https://repobeats.axiom.co/api/embed/ec3e74d795ed805a0fce67c0b64c3f08872e7945.svg "Repobeats analytics image") ## License -Licensed as MIT open source. +Licensed under the MPL 2.0 open source license.
diff --git a/examples/nextjs/CHANGELOG.md b/examples/nextjs/CHANGELOG.md index 20c63a52..3e4a358e 100644 --- a/examples/nextjs/CHANGELOG.md +++ b/examples/nextjs/CHANGELOG.md @@ -1,5 +1,16 @@ # nextjs-example +## 0.0.12 + +### Patch Changes + +- Updated dependencies +- Updated dependencies +- Updated dependencies +- Updated dependencies + - r18gs@1.0.0 + - shared-ui@0.0.0 + ## 0.0.11 ### Patch Changes diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index b92f5d3f..3d0ca9d6 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "nextjs-example", - "version": "0.0.11", + "version": "0.0.12", "private": true, "scripts": { "dev": "next dev", diff --git a/examples/remix/CHANGELOG.md b/examples/remix/CHANGELOG.md index f9cd871d..d5005680 100644 --- a/examples/remix/CHANGELOG.md +++ b/examples/remix/CHANGELOG.md @@ -1,5 +1,16 @@ # remix-example +## 0.0.12 + +### Patch Changes + +- Updated dependencies +- Updated dependencies +- Updated dependencies +- Updated dependencies + - r18gs@1.0.0 + - shared-ui@0.0.0 + ## 0.0.11 ### Patch Changes diff --git a/examples/remix/package.json b/examples/remix/package.json index 015a2b3c..95be728d 100644 --- a/examples/remix/package.json +++ b/examples/remix/package.json @@ -1,6 +1,6 @@ { "name": "remix-example", - "version": "0.0.11", + "version": "0.0.12", "private": true, "sideEffects": false, "type": "module", diff --git a/examples/vite/CHANGELOG.md b/examples/vite/CHANGELOG.md index 0604d258..8952f6da 100644 --- a/examples/vite/CHANGELOG.md +++ b/examples/vite/CHANGELOG.md @@ -1,5 +1,16 @@ # vite-example +## 0.0.12 + +### Patch Changes + +- Updated dependencies +- Updated dependencies +- Updated dependencies +- Updated dependencies + - r18gs@1.0.0 + - shared-ui@0.0.0 + ## 0.0.11 ### Patch Changes diff --git a/examples/vite/package.json b/examples/vite/package.json index d1ce5450..ff8b5748 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -1,7 +1,7 @@ { "name": "vite-example", "private": true, - "version": "0.0.11", + "version": "0.0.12", "type": "module", "scripts": { "dev": "vite --port 3001", diff --git a/lib/r18gs/CHANGELOG.md b/lib/r18gs/CHANGELOG.md index b0319ffa..ecb68077 100644 --- a/lib/r18gs/CHANGELOG.md +++ b/lib/r18gs/CHANGELOG.md @@ -1,5 +1,20 @@ # r18gs +## 1.0.0 + +### Major Changes + +- Removed `serverValue` field as it should always be same as `value`. For more details please check out documentation of useSyncExternalStore hook provided by react. + +### Minor Changes + +- Added persist plugin to create state that is persisted and synced between browser contexts. +- Added `useRGSWithPlugins` hook to support extending the store functionality with plugins. + +### Patch Changes + +- Refactored to reduce bundle size and improve stability and reduce likelyhood of bugs. + ## 0.2.0 ### Minor Changes diff --git a/lib/r18gs/index.ts b/lib/r18gs/index.ts deleted file mode 100644 index e5de85df..00000000 --- a/lib/r18gs/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useRGS from "./src"; - -export default useRGS; - -export * from "./src"; diff --git a/lib/r18gs/package.json b/lib/r18gs/package.json index f579d2f8..0c01c1ed 100644 --- a/lib/r18gs/package.json +++ b/lib/r18gs/package.json @@ -2,7 +2,7 @@ "name": "r18gs", "author": "Mayank Kumar Chaudhari ", "private": false, - "version": "0.2.0", + "version": "1.0.0", "description": "A simple yet elegant, light weight, react18 global store to replace Zustand for better tree shaking.", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -19,7 +19,7 @@ }, "homepage": "https://react18-global-store.vercel.app/", "sideEffects": false, - "license": "MIT", + "license": "MPL-2.0", "scripts": { "test": "vitest run --coverage", "build": "tsup src && tsc -p tsconfig-build.json", @@ -42,6 +42,10 @@ "tsconfig": "workspace:*", "tsup": "^8.0.2", "typedoc": "^0.25.13", + "typedoc-plugin-mdn-links": "^3.1.23", + "typedoc-plugin-missing-exports": "^2.2.0", + "typedoc-plugin-rename-defaults": "^0.7.0", + "typedoc-plugin-zod": "^1.1.2", "typescript": "^5.4.5", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.5.2" @@ -50,10 +54,13 @@ "@types/react": "16.8 - 18", "react": "16.8 - 18" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/mayank1513" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mayank1513" + }, + "https://pages.razorpay.com/mayank1513" + ], "keywords": [ "react18-global-store", "nextjs", diff --git a/lib/r18gs/server.ts b/lib/r18gs/server.ts deleted file mode 100644 index dcc241ce..00000000 --- a/lib/r18gs/server.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** to make sure import statements remain same for monorepo setup and install via npm */ - -export * from "./src/server"; diff --git a/lib/r18gs/src/index.ts b/lib/r18gs/src/index.ts index 3d70ae81..baf1b8d6 100644 --- a/lib/r18gs/src/index.ts +++ b/lib/r18gs/src/index.ts @@ -1,4 +1,28 @@ -import useRGS from "./use-rgs"; +/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style -- as ! operator is forbidden by eslint*/ +import { createHook, createSetter, createSubcriber, globalRGS } from "./utils"; + +import type { SetStateAction } from "./utils"; + export type { SetterArgType, SetStateAction, Plugin } from "./utils"; -export default useRGS; +/** + * Use this hook similar to `useState` hook. + * The difference is that you need to pass a + * unique key - unique across the app to make + * this state accessible to all client components. + * + * @example + * ```tsx + * const [state, setState] = useRGS("counter", 1); + * ``` + * + * @param key - Unique key to identify the store. + * @param value - Initial value of the store. + * @returns - A tuple (Ordered sequance of values) containing the state and a function to set the state. + */ +export default function useRGS(key: string, value?: T): [T, SetStateAction] { + /** Initialize the named store when invoked for the first time. */ + if (!globalRGS[key]) globalRGS[key] = [value, [], createSetter(key), createSubcriber(key)]; + + return createHook(key); +} diff --git a/lib/r18gs/src/plugins/index.ts b/lib/r18gs/src/plugins/index.ts new file mode 100644 index 00000000..8818f742 --- /dev/null +++ b/lib/r18gs/src/plugins/index.ts @@ -0,0 +1 @@ +export * from "./persist"; diff --git a/lib/r18gs/src/plugins/persist-and-sync.ts b/lib/r18gs/src/plugins/persist-and-sync.ts deleted file mode 100644 index 9d04c9cf..00000000 --- a/lib/r18gs/src/plugins/persist-and-sync.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Plugin } from ".."; - -/** - * A plugin that persists and syncs RGS store between tabs. - * - * @returns A plugin that persists and syncs a value between tabs. - */ -function persistAndSyncPlugin(): Plugin { - return { - init(key, value, _, mutate) { - if (typeof window === "undefined") return; - const persistedValue = localStorage.getItem(key); - const newVal = JSON.parse(persistedValue || "{}").val; - if (newVal) mutate(newVal); - - addEventListener("storage", e => { - if (e.key === key && e.newValue) { - const newVal = JSON.parse(e.newValue).val; - if (newVal !== undefined) mutate(newVal); - } - }); - }, - onChange(key, value) { - if (typeof window !== "undefined") { - localStorage.setItem(key, JSON.stringify({ val: value })); - } - }, - }; -} - -export default persistAndSyncPlugin; diff --git a/lib/r18gs/src/plugins/persist.ts b/lib/r18gs/src/plugins/persist.ts new file mode 100644 index 00000000..d364eb4b --- /dev/null +++ b/lib/r18gs/src/plugins/persist.ts @@ -0,0 +1,65 @@ +import { Plugin } from ".."; + +export interface PersistOptions { + /** @defaultValue true */ + sync?: boolean; + /** @defaultValue local */ + storage?: "local" | "session" | "cookie"; +} + +/** get stored item */ +function getItem(key: string, options?: PersistOptions) { + const cookie = document.cookie.split("; ").find(c => c.startsWith(key)); + switch (options?.storage) { + case "cookie": + return cookie?.split("=")[1]; + case "session": + return sessionStorage.getItem(key); + default: + return localStorage.getItem(key); + } +} + +/** set item to persistant store */ +function setItem(key: string, value: string, options?: PersistOptions) { + switch (options?.storage) { + case "cookie": + document.cookie = `${key}=${value}; max-age=31536000; SameSite=Strict;`; + if (options.sync ?? true) sessionStorage.setItem(key, value); + return; + case "session": + sessionStorage.setItem(key, value); + return; + default: + localStorage.setItem(key, value); + } +} +/** + * A plugin that persists and syncs RGS store between tabs. + * + * @returns A plugin that persists and syncs a value between tabs. + */ +export function persist(options?: PersistOptions): Plugin { + return { + init(key, _, mutate) { + if (typeof window === "undefined") return; + const persistedValue = getItem(key, options); + const newVal = JSON.parse(persistedValue || "{}").val; + if (newVal) mutate(newVal); + + if (options?.sync ?? true) { + addEventListener("storage", e => { + if (e.key === key && e.newValue) { + const newVal = JSON.parse(e.newValue).val; + if (newVal !== undefined) mutate(newVal); + } + }); + } + }, + onChange(key, value) { + if (typeof window !== "undefined") { + setItem(key, JSON.stringify({ val: value }), options); + } + }, + }; +} diff --git a/lib/r18gs/src/use-rgs.ts b/lib/r18gs/src/use-rgs.ts deleted file mode 100644 index 11070222..00000000 --- a/lib/r18gs/src/use-rgs.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style -- as ! operator is forbidden by eslint*/ -import { createHook, createSetter, createSubcriber, globalRGS } from "./utils"; - -import type { SetStateAction } from "./utils"; - -/** - * Use this hook similar to `useState` hook. - * The difference is that you need to pass a - * unique key - unique across the app to make - * this state accessible to all client components. - * - * @example - * ```tsx - * const [state, setState] = useRGS("counter", 1); - * ``` - * - * @param key - Unique key to identify the store. - * @param value - Initial value of the store. - * @param serverValue - Server value of the store. - * @returns - A tuple (Ordered sequance of values) containing the state and a function to set the state. - */ -export default function useRGS(key: string, value?: T, serverValue?: T): [T, SetStateAction] { - /** Initialize the named store when invoked for the first time. */ - if (!globalRGS[key]) - globalRGS[key] = [value, serverValue, [], createSetter(key), createSubcriber(key)]; - - return createHook(key); -} diff --git a/lib/r18gs/src/utils.ts b/lib/r18gs/src/utils.ts index 0adcd1a0..ede2ecae 100644 --- a/lib/r18gs/src/utils.ts +++ b/lib/r18gs/src/utils.ts @@ -9,8 +9,8 @@ export type SetStateAction = (value: SetterArgType) => void; /** * This is a hack to reduce lib size + readability + not encouraging direct access to globalThis */ -const [VALUE, SERVER_VALUE, LISTENERS, SETTER, SUBSCRIBER] = [0, 1, 2, 3, 4]; -type RGS = [unknown, unknown, Listener[], SetStateAction, Subscriber]; +const [VALUE, LISTENERS, SETTER, SUBSCRIBER] = [0, 1, 2, 3, 4]; +type RGS = [unknown, Listener[], SetStateAction | null, Subscriber]; declare global { // eslint-disable-next-line no-var -- var required for global declaration. @@ -30,10 +30,9 @@ function triggerListeners(rgs: RGS) { export function createSubcriber(key: string): Subscriber { return listener => { const rgs = globalRGS[key] as RGS; - const listeners = rgs[LISTENERS] as Listener[]; - listeners.push(listener); + (rgs[LISTENERS] as Listener[]).push(listener); return () => { - rgs[LISTENERS] = listeners.filter(l => l !== listener); + rgs[LISTENERS] = (rgs[LISTENERS] as Listener[]).filter(l => l !== listener); }; }; } @@ -43,44 +42,36 @@ export function createSetter(key: string): SetStateAction { return val => { const rgs = globalRGS[key] as RGS; rgs[VALUE] = val instanceof Function ? val(rgs[VALUE] as T) : val; - triggerListeners(rgs); + (rgs[LISTENERS] as Listener[]).forEach(listener => listener()); }; } /** Extract coomon create hook logic to utils */ export function createHook(key: string): [T, SetStateAction] { const rgs = globalRGS[key] as RGS; - /** Function to set the state. */ - const setRGState = rgs[SETTER] as SetStateAction; - /** Function to get snapshot of the state. */ - const getSnap = () => rgs[VALUE] as T; - /** Function to get server snapshot. Returns server value is provided else the default value. */ - const getServerSnap = () => (rgs[SERVER_VALUE] ?? rgs[VALUE]) as T; - - const val = useSyncExternalStore(rgs[SUBSCRIBER] as Subscriber, getSnap, getServerSnap); - return [val, setRGState]; + const val = useSyncExternalStore(rgs[SUBSCRIBER] as Subscriber, () => rgs[VALUE] as T); + return [val, rgs[SETTER] as SetStateAction]; } -type Mutate = (value?: T, serverValue?: T) => void; +type Mutate = (value?: T) => void; export type Plugin = { - init?: (key: string, value: T | undefined, serverValue: T | undefined, mutate: Mutate) => void; - onChange?: (key: string, value?: T, serverValue?: T) => void; + init?: (key: string, value: T | undefined, mutate: Mutate) => void; + onChange?: (key: string, value?: T) => void; }; let allExtentionsInitialized = false; /** Initialize extestions - wait for previous plugins's promise to be resolved before processing next */ async function initPlugins(key: string, plugins: Plugin[]) { const rgs = globalRGS[key] as RGS; - /** Mutate function to update the value and server value */ - const mutate: Mutate = (newValue, newServerValue) => { + /** Mutate function to update the value */ + const mutate: Mutate = newValue => { rgs[VALUE] = newValue; - rgs[SERVER_VALUE] = newServerValue; triggerListeners(rgs); }; for (const plugin of plugins) { /** Next plugins initializer will get the new value if updated by previous one */ - await plugin.init?.(key, rgs[VALUE] as T, rgs[SERVER_VALUE] as T, mutate); + await plugin.init?.(key, rgs[VALUE] as T, mutate); } allExtentionsInitialized = true; } @@ -89,9 +80,14 @@ async function initPlugins(key: string, plugins: Plugin[]) { export function initWithPlugins( key: string, value?: T, - serverValue?: T, plugins: Plugin[] = [], + doNotInit = false, ) { + if (doNotInit) { + /** You will not have access to the setter until initialized */ + globalRGS[key] = [value, [], null, createSubcriber(key)]; + return; + } /** setter function to set the state. */ const setterWithPlugins: SetStateAction = val => { /** Do not allow mutating the store before all extentions are initialized */ @@ -99,9 +95,40 @@ export function initWithPlugins( const rgs = globalRGS[key] as RGS; rgs[VALUE] = val instanceof Function ? val(rgs[VALUE] as T) : val; triggerListeners(rgs); - plugins.forEach(plugin => plugin.onChange?.(key, rgs[VALUE] as T, rgs[SERVER_VALUE] as T)); + plugins.forEach(plugin => plugin.onChange?.(key, rgs[VALUE] as T)); }; - globalRGS[key] = [value, serverValue, [], setterWithPlugins, createSubcriber(key)]; + const rgs = globalRGS[key]; + if (rgs) { + rgs[VALUE] = value; + rgs[SETTER] = setterWithPlugins; + } else globalRGS[key] = [value, [], setterWithPlugins, createSubcriber(key)]; initPlugins(key, plugins); } + +/** + * Use this hook similar to `useState` hook. + * The difference is that you need to pass a + * unique key - unique across the app to make + * this state accessible to all client components. + * + * @example + * ```tsx + * const [state, setState] = useRGS("counter", 1); + * ``` + * + * @param key - Unique key to identify the store. + * @param value - Initial value of the store. + * @param plugins - Plugins to be applied to the store. + * @param doNotInit - Do not initialize the store. Useful when you want to initialize the store later. Note that the setter function is not available until the store is initialized. + * @returns - A tuple (Ordered sequance of values) containing the state and a function to set the state. + */ +export function useRGSWithPlugins( + key: string, + value?: T, + plugins?: Plugin[], + doNotInit = false, +): [T, SetStateAction] { + if (!globalRGS[key]?.[SETTER]) initWithPlugins(key, value, plugins, doNotInit); + return createHook(key); +} diff --git a/lib/r18gs/src/with-plugins.ts b/lib/r18gs/src/with-plugins.ts index 3a5042ff..70a2db46 100644 --- a/lib/r18gs/src/with-plugins.ts +++ b/lib/r18gs/src/with-plugins.ts @@ -1,61 +1,58 @@ /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style -- as ! operator is forbidden by eslint*/ -import { createHook, globalRGS, initWithPlugins } from "./utils"; +import { useRGSWithPlugins } from "./utils"; import type { Plugin, SetStateAction } from "./utils"; /** - * Use this hook similar to `useState` hook. - * The difference is that you need to pass a - * unique key - unique across the app to make - * this state accessible to all client components. - * + * Creates a store with plugins. * @example + * * ```tsx - * const [state, setState] = useRGS("counter", 1); + * // in hook file, e.g., store.ts + * export const useMyRGS = create(key, value, plugins); + * + * // in component file + * const [state, setState] = useMyRGS(); * ``` * * @param key - Unique key to identify the store. * @param value - Initial value of the store. - * @param serverValue - Server value of the store. * @param plugins - Plugins to be applied to the store. - * @returns - A tuple (Ordered sequance of values) containing the state and a function to set the state. + * @returns - A hook function that returns a tuple (Ordered sequence of values) containing the state and a function to set the state. */ -export function useRGSWithPlugins( +export function create( key: string, value?: T, - serverValue?: T, plugins?: Plugin[], -): [T, SetStateAction] { - if (!globalRGS[key]) initWithPlugins(key, value, serverValue, plugins); - return createHook(key); +): () => [T, SetStateAction] { + return () => useRGSWithPlugins(key, value, plugins); } /** - * Use this hook similar to `useState` hook. - * The difference is that you need to pass a - * unique key - unique across the app to make - * this state accessible to all client components. + * Creates a hook similar to useRGS, but with plugins to be applied on first invocation. * - * @example - * ```tsx - * // in hook file - * export const useRGS = create(key, value, serverValue, plugins); - * - * // in component file - * const [state, setState] = useRGS(); - * ``` - * - * @param key - Unique key to identify the store. - * @param value - Initial value of the store. - * @param serverValue - Server value of the store. * @param plugins - Plugins to be applied to the store. - * @returns - A hook funciton that returns a tuple (Ordered sequance of values) containing the state and a function to set the state. + * @returns A hook that automatically initializes the store (if not already initialized) with the given plugins. */ -export function create( - key: string, - value?: T, - serverValue?: T, - plugins?: Plugin[], -): () => [T, SetStateAction] { - return () => useRGSWithPlugins(key, value, serverValue, plugins); +export function withPlugins( + plugins: Plugin[], +): (key: string, value?: U, doNotInit?: boolean) => [U, SetStateAction] { + /** + * Creates a hook similar to useRGS, with plugins applied on first invocation. + * + * @param key - Unique key to identify the store. + * @param value - Initial value of the store. + * @param doNotInit - If true, the store won't be initialized immediately. Defaults to false. Useful when you want to initialize the store later. Note that the setter function is not available until the store is initialized. + * @returns A tuple containing the state value and its setter function. + */ + const hookWithPlugins = ( + key: string, + value?: U, + doNotInit = false, + ): [U, SetStateAction] => + useRGSWithPlugins(key, value, plugins as unknown as Plugin[], doNotInit); + + return hookWithPlugins; } + +export { useRGSWithPlugins }; diff --git a/lib/r18gs/tests/create.test.tsx b/lib/r18gs/tests/create.test.tsx index 72615c0e..cfa5d77e 100644 --- a/lib/r18gs/tests/create.test.tsx +++ b/lib/r18gs/tests/create.test.tsx @@ -1,46 +1,74 @@ import { describe, test } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; -import { create } from "../src/with-plugins"; -import persistAndSyncPlugin from "../src/plugins/persist-and-sync"; +import { create, withPlugins } from "../src/with-plugins"; +import { persist } from "../src/plugins/persist"; import { ChangeEvent, useCallback } from "react"; -const COUNTER_RGS_KEY = "count"; - -const useMyRGS = create(COUNTER_RGS_KEY, 0, 0, [persistAndSyncPlugin()]); - +const COUNTER_RGS_KEY = "counter"; const TESTID_INPUT = "in1"; const TESTID_DISPLAY = "d1"; +const COUNTER2_RGS_KEY = "counter-2"; +const TESTID_INPUT2 = "in2"; +const TESTID_DISPLAY2 = "d2"; + +const useMyRGS = create(COUNTER_RGS_KEY, 0, [persist({ storage: "cookie" })]); + +const useMyRGS2 = withPlugins([persist()]); + function Component1() { const [count, setCount] = useMyRGS(); + const [count2, setCount2] = useMyRGS2(COUNTER2_RGS_KEY, 0); const handleChange = useCallback( (e: ChangeEvent) => { setCount(parseInt(e.target.value)); }, [setCount], ); + const handleChange2 = useCallback( + (e: ChangeEvent) => { + setCount2(parseInt(e.target.value)); + }, + [setCount2], + ); return (
+
); } function Component2() { const [count] = useMyRGS(); - console.log("c2", { count }); - return

{count}

; + const [count2] = useMyRGS2(COUNTER2_RGS_KEY, 10, true); + return ( + <> +

{count}

+

{count2}

+ + ); } describe("React18GlobalStore", () => { test("check state update to multiple components", async ({ expect }) => { - render(); render(); + render(); /** Await and allow for the state to update from localStorate */ await new Promise(resolve => setTimeout(resolve, 100)); await act(() => fireEvent.input(screen.getByTestId(TESTID_INPUT), { target: { value: 5 } })); expect(screen.getByTestId(TESTID_DISPLAY).textContent).toBe("5"); - expect(JSON.parse(localStorage.getItem(COUNTER_RGS_KEY) ?? "{}").val).toBe(5); + expect(JSON.parse(sessionStorage.getItem(COUNTER_RGS_KEY) ?? "{}").val).toBe(5); + }); + + test("check state update to multiple components for withPlugins", async ({ expect }) => { + render(); + render(); + /** Await and allow for the state to update from localStorate */ + await new Promise(resolve => setTimeout(resolve, 100)); + await act(() => fireEvent.input(screen.getByTestId(TESTID_INPUT2), { target: { value: 5 } })); + expect(screen.getByTestId(TESTID_DISPLAY2).textContent).toBe("5"); + expect(JSON.parse(localStorage.getItem(COUNTER2_RGS_KEY) ?? "{}").val).toBe(5); }); test("storage event", async ({ expect }) => { diff --git a/lib/r18gs/tests/plugins/persist.test.tsx b/lib/r18gs/tests/plugins/persist.test.tsx new file mode 100644 index 00000000..3fceadb5 --- /dev/null +++ b/lib/r18gs/tests/plugins/persist.test.tsx @@ -0,0 +1,79 @@ +import { describe, test } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { create } from "../../src/with-plugins"; +import { PersistOptions, persist } from "../../src/plugins"; +import { ChangeEvent, useCallback } from "react"; + +const TESTID_INPUT = "in1"; +const TESTID_DISPLAY = "d1"; + +const persistOptions: PersistOptions[] = [ + {}, + { storage: "local" }, + { storage: "session" }, + { storage: "cookie" }, + { sync: false }, +]; + +describe("React18GlobalStore", () => { + persistOptions.forEach((options, i) => { + const COUNTER_RGS_KEY = `count-${i}`; + const useMyRGS = create(COUNTER_RGS_KEY, 0, [persist(options)]); + + function Component1() { + const [count, setCount] = useMyRGS(); + const handleChange = useCallback( + (e: ChangeEvent) => { + setCount(parseInt(e.target.value)); + }, + [setCount], + ); + return ( +
+ +
+ ); + } + + function Component2() { + const [count] = useMyRGS(); + return

{count}

; + } + + test(`check state update to multiple components: ${JSON.stringify(options)}`, async ({ + expect, + }) => { + render(); + render(); + /** Await and allow for the state to update from localStorate */ + await new Promise(resolve => setTimeout(resolve, 100)); + await act(() => fireEvent.input(screen.getByTestId(TESTID_INPUT), { target: { value: 5 } })); + expect(screen.getByTestId(TESTID_DISPLAY).textContent).toBe("5"); + expect( + JSON.parse( + ((options.storage ?? "local") === "local" ? localStorage : sessionStorage).getItem( + COUNTER_RGS_KEY, + ) ?? "{}", + ).val, + ).toBe(5); + }); + + if (options.sync ?? true) { + test("storage event", async ({ expect }) => { + render(); + render(); + /** Await and allow for the state to update from localStorate */ + await new Promise(resolve => setTimeout(resolve, 100)); + await act(() => + window.dispatchEvent( + new StorageEvent("storage", { + key: COUNTER_RGS_KEY, + newValue: JSON.stringify({ val: 15 }), + }), + ), + ); + expect(screen.getByTestId(TESTID_DISPLAY).textContent).toBe("15"); + }); + } + }); +}); diff --git a/lib/r18gs/tests/use-rgs.test.tsx b/lib/r18gs/tests/use-rgs.test.tsx index ecd7751e..ceec4e18 100644 --- a/lib/r18gs/tests/use-rgs.test.tsx +++ b/lib/r18gs/tests/use-rgs.test.tsx @@ -4,6 +4,8 @@ import useRGS from "../src"; import { ChangeEvent, useCallback } from "react"; const COUNT_RGS_KEY = "count"; +const TESTID_INPUT = "in1"; +const TESTID_DISPLAY = "d1"; function Component1() { const [count, setCount] = useRGS(COUNT_RGS_KEY, 0); @@ -15,21 +17,21 @@ function Component1() { ); return (
- +
); } function Component2() { const [count] = useRGS(COUNT_RGS_KEY); - return

{count}

; + return

{count}

; } describe("React18GlobalStore", () => { test("check state update to multiple components", async ({ expect }) => { render(); render(); - await act(() => fireEvent.input(screen.getByTestId("input"), { target: { value: 5 } })); - expect(screen.getByTestId("display").textContent).toBe("5"); + await act(() => fireEvent.input(screen.getByTestId(TESTID_INPUT), { target: { value: 5 } })); + expect(screen.getByTestId(TESTID_DISPLAY).textContent).toBe("5"); }); }); diff --git a/lib/r18gs/tsconfig.doc.json b/lib/r18gs/tsconfig.doc.json deleted file mode 100644 index 70249fd8..00000000 --- a/lib/r18gs/tsconfig.doc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": ["src"], - "exclude": ["**/*.test.tsx", "**/index.ts"] -} diff --git a/lib/r18gs/typedoc.config.js b/lib/r18gs/typedoc.config.js index dec526f7..aa844bf8 100644 --- a/lib/r18gs/typedoc.config.js +++ b/lib/r18gs/typedoc.config.js @@ -2,8 +2,15 @@ module.exports = { name: "Code Documentation", entryPoints: ["./src"], + exclude: ["**/*.test.tsx"], entryPointStrategy: "Expand", - tsconfig: "./tsconfig.doc.json", out: "../../docs", commentStyle: "all", + searchInComments: true, + plugin: [ + "typedoc-plugin-mdn-links", + "typedoc-plugin-rename-defaults", + "typedoc-plugin-missing-exports", + "typedoc-plugin-zod", + ], }; diff --git a/lib/r18gs/vitest.config.ts b/lib/r18gs/vitest.config.ts index c264278a..9b0a01dc 100644 --- a/lib/r18gs/vitest.config.ts +++ b/lib/r18gs/vitest.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ setupFiles: [], coverage: { include: ["src/**"], - exclude: ["src/**/index.ts", "src/**/declaration.d.ts"], reporter: ["text", "json", "clover", "html"], }, }, diff --git a/md-docs/1.getting-started.md b/md-docs/1.getting-started.md new file mode 100644 index 00000000..e1d28c0d --- /dev/null +++ b/md-docs/1.getting-started.md @@ -0,0 +1,84 @@ +# Quick Start + +Welcome to the quick guide for using this library. + +## Installation + +To get started, you can install the package via your preferred package manager. Here are a few examples: + +```bash +$ pnpm add r18gs +``` + +or + +```bash +$ npm install r18gs +``` + +or + +```bash +$ yarn add r18gs +``` + +## Usage + +Utilize this hook similarly to the `useState` hook. However, ensure to pass a unique key, unique across the app, to identify and make this state accessible to all client components. + +```tsx +const [state, setState] = useRGS("counter", 1); +``` + +You can access the same state across all client-side components using a unique key. + +> It's advisable to store your keys in a separate file to prevent typos and unnecessary conflicts. + +### Example + +```tsx +// constants/global-states.ts +export const COUNTER = "counter"; +``` + +```tsx +// components/display.tsx +"use client"; + +import useRGS from "r18gs"; +import { COUNTER } from "../constants/global-states"; + +export default function Display() { + const [count] = useRGS(COUNTER); + return ( +
+

Client Component 2

+ {count} +
+ ); +} +``` + +```tsx +// components/counter.tsx +"use client"; + +import useRGS from "r18gs"; +import { COUNTER } from "../constants/global-states"; + +export default function Counter() { + const [count, setCount] = useRGS(COUNTER, 0); + return ( +
+

Client Component 1

+ { + setCount(parseInt(e.target.value.trim())); + }} + type="number" + value={count} + /> +
+ ); +} +``` diff --git a/md-docs/2.leveraging-plugins.md b/md-docs/2.leveraging-plugins.md new file mode 100644 index 00000000..e8bd53c2 --- /dev/null +++ b/md-docs/2.leveraging-plugins.md @@ -0,0 +1,64 @@ +# Leveraging Plugins + +Enhance your store's functionality by utilizing either the `create` function, `withPlugins` function, or the `useRGSWithPlugins` hook from `r18gs/dist/with-plugins`. This enables features such as storing to local storage, among others. + +## Creating a Store with the `create` Function + +```tsx +// store.ts +import { create } from "r18gs/dist/with-plugins"; +import { persist } from "r18gs/dist/plugins"; /** You can create your own plugin or import third-party plugins */ + +export const useMyPersistentCounterStore = create("persistent-counter", 0, [persist()]); +``` + +Now you can utilize `useMyPersistentCounterStore` similar to `useState` without specifying an initial value. + +```tsx +// inside your component +const [persistedCount, setPersistedCount] = useMyPersistentCounterStore(); +``` + +## Utilizing the `useRGSWithPlugins` Hook + +This function is beneficial if your requirements dictate that your `key` and/or initial value will depend on some props, etc. Or for some other reason you want to initialize the store from within a component (You need to use some variables available inside the component). + +```tsx +import { useRGSWithPlugins } from "r18gs/dist/with-plugin"; + +export function MyComponent(props: MyComponentProps) { + const [state, setState] = useRGSWithPlugins( + props.key, + props.initialVal, + props.plugins, + props.doNotInit, + ); + // ... +} +``` + +### `doNotInit` + +In some cases, you may not want to initialize the store immediately. In such cases, you can pass the fourth argument (`doNotInit`) as `true`. The default value is false. + +- When this argument is set to `true`, the store is created, but the setter function is set to null. Thus, you can access the initial value set by the first component that triggered this hook. However, it cannot be modified until the store is initialized, i.e., the hook is invoked by a component setting `doNotInit` to false (or skipping this argument). + +**Use case**: When you need that the store be used in many components, however, it should be initialized in a particular component only. + +## Creating a Custom Hook using the `withPlugins` Higher-Order Function + +This is a utility function that will be very helpful when you want to use the `useRGSWithPlugins` hook with the same plugins in multiple components. + +```ts +// custom hook file, e.g., store.ts +export const useMyRGS = withPlugins([plugin1, plugin2, ...]); +``` + +```tsx +export function MyComponent(props: MyComponentProps) { + const [state, setState] = useMyRGS(props.key, props.initialVal, props.doNotInit); + // ... +} +``` + +You can also create your own plugins. Refer to [Creating Plugins](./3.creating-plugins.md). diff --git a/md-docs/3.creating-plugins.md b/md-docs/3.creating-plugins.md new file mode 100644 index 00000000..b7d3cbd8 --- /dev/null +++ b/md-docs/3.creating-plugins.md @@ -0,0 +1 @@ +# Creating Plugins diff --git a/packages/shared-ui/hooks/use-persistant-counter.ts b/packages/shared-ui/hooks/use-persistant-counter.ts index 5852f566..18fdf006 100644 --- a/packages/shared-ui/hooks/use-persistant-counter.ts +++ b/packages/shared-ui/hooks/use-persistant-counter.ts @@ -1,6 +1,4 @@ import { create } from "r18gs/dist/with-plugins"; -import persistNSyncPlugin from "r18gs/dist/plugins/persist-and-sync"; +import { persist } from "r18gs/dist/plugins"; -export const useMyPersistantCounterStore = create("persistant-counter", 1, 1, [ - persistNSyncPlugin(), -]); +export const useMyPersistentCounterStore = create("persistent-counter", 1, [persist()]); diff --git a/packages/shared-ui/src/root/persistant-counter.tsx b/packages/shared-ui/src/root/persistant-counter.tsx index 48ea33c6..a504ab6a 100644 --- a/packages/shared-ui/src/root/persistant-counter.tsx +++ b/packages/shared-ui/src/root/persistant-counter.tsx @@ -1,7 +1,7 @@ "use client"; import { ChangeEvent, useCallback } from "react"; -import { useMyPersistantCounterStore } from "../../hooks/use-persistant-counter"; +import { useMyPersistentCounterStore } from "../../hooks/use-persistant-counter"; /** * Persistant Counter @@ -9,7 +9,7 @@ import { useMyPersistantCounterStore } from "../../hooks/use-persistant-counter" * @returns {JSX.Element} */ export default function PersistantCounter() { - const [count, setCount] = useMyPersistantCounterStore(); + const [count, setCount] = useMyPersistentCounterStore(); const handleChange = useCallback( (e: ChangeEvent) => {