Skip to content

Commit

Permalink
Merge pull request #35 from react18-tools/feature/selectors
Browse files Browse the repository at this point in the history
Feature/selectors
  • Loading branch information
mayank1513 authored Dec 17, 2024
2 parents 07c6aad + 9de6342 commit 4ab636e
Show file tree
Hide file tree
Showing 38 changed files with 665 additions and 151 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ jobs:
git pull
- run: npm i -g pnpm && pnpm i
name: Install dependencies
- name: Test
run: npm test
- run: git stash --include-untracked
name: clean up working directory
- run: npx @turbo/codemod update . && pnpm update --latest -r
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- run: npm i -g pnpm && pnpm i
name: Install dependencies
- name: Run unit tests
run: pnpm test
run: cd lib && pnpm test
- name: Upload coverage reports to Codecov
continue-on-error: true
uses: codecov/codecov-action@v4
Expand Down
73 changes: 41 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,81 +1,90 @@
# React18GlobalStore <img src="https://raw.githubusercontent.com/mayank1513/mayank1513/main/popper.png" style="height: 40px"/>

[![test](https://github.com/react18-tools/react18-global-store/actions/workflows/test.yml/badge.svg)](https://github.com/react18-tools/react18-global-store/actions/workflows/test.yml) [![Maintainability](https://api.codeclimate.com/v1/badges/ec3140063acd8df82481/maintainability)](https://codeclimate.com/github/react18-tools/react18-global-store/maintainability) [![codecov](https://codecov.io/gh/react18-tools/react18-global-store/graph/badge.svg)](https://codecov.io/gh/react18-tools/react18-global-store) [![Version](https://img.shields.io/npm/v/r18gs.svg?colorB=green)](https://www.npmjs.com/package/r18gs) [![Downloads](https://img.jsdelivr.com/img.shields.io/npm/d18m/r18gs.svg)](https://www.npmjs.com/package/r18gs) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/r18gs) [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/from-referrer/)
[![Test](https://github.com/react18-tools/react18-global-store/actions/workflows/test.yml/badge.svg)](https://github.com/react18-tools/react18-global-store/actions/workflows/test.yml)
[![Maintainability](https://api.codeclimate.com/v1/badges/ec3140063acd8df82481/maintainability)](https://codeclimate.com/github/react18-tools/react18-global-store/maintainability)
[![Code Coverage](https://codecov.io/gh/react18-tools/react18-global-store/graph/badge.svg)](https://codecov.io/gh/react18-tools/react18-global-store)
[![Version](https://img.shields.io/npm/v/r18gs.svg?colorB=green)](https://www.npmjs.com/package/r18gs)
[![Downloads](https://img.jsdelivr.com/img.shields.io/npm/d18m/r18gs.svg)](https://www.npmjs.com/package/r18gs)
![Bundle Size](https://img.shields.io/bundlephobia/minzip/r18gs)
[![Gitpod Ready](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/from-referrer/)

## Motivation

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.
> 🌐 **Live Demo with Code**: [https://r18gs.vercel.app/](https://r18gs.vercel.app/)
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.
## Motivation

### Important Announcement
While developing libraries utilizing React 18 features with Zustand, I encountered issues with tree-shaking when importing from specific folders. This caused the creation of multiple Zustand stores, resulting in bugs and unnecessary JavaScript code execution.

The default export from `r18gs` is [deprecated](https://github.com/react18-tools/react18-global-store/discussions/31). Please switch to using `import { useRGS } from "r18gs"` instead. The default export will be removed in the next major release.
To address this, I created a lightweight, minimalistic store designed for shared states that optimizes tree-shaking and supports component imports from separate files.

## Features

✅ Full TypeScript Support

✅ Unleash the full power of React18 Server components

✅ Compatible with all build systems/tools/frameworks for React18
**Full TypeScript Support**
**Unlock the Full Power of React18 Server Components**
**Compatible with All React18 Build Systems and Frameworks**
**Comprehensive Documentation with [Typedoc](https://react18-tools.github.io/react18-global-store)**
**Examples for Next.js, Vite, and Remix**
**Seamlessly Works with Selectors**

✅ Documented with [Typedoc](https://react18-tools.github.io/react18-global-store) ([Docs](https://react18-tools.github.io/react18-global-store))
## Simple Global State Across Components

✅ Examples for Next.js, Vite, and Remix

## Simple Global State Shared Across Multiple 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.
Use the `useRGS` hook just like `useState`, but with a unique key to make the state accessible across components.

```tsx
const [state, setState] = useRGS<number>("counter", 1);
```

**_or_**
Or initialize using a function:

```tsx
const [state, setState] = useRGS<number>("counter", () => 1);
```

> For detailed instructions, see [Getting Started](./md-docs/1.getting-started.md)
> 🔗 **Getting Started**: [Guide](./md-docs/1.getting-started.md)
## What's New?

🚀 **Now Supports Selectors for Complex Stores**
Explore more at [https://r18gs.vercel.app/](https://r18gs.vercel.app/).

## Using Plugins

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.
Extend the store's functionality with the `create` function, `withPlugins` function, or the `useRGSWithPlugins` hook. Features like persistence to local storage can be easily integrated.

```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 */
import { persist } from "r18gs/dist/plugins"; // Use third-party or custom plugins

export const useMyPersistentCounterStore = create<number>("persistent-counter", 0, [persist()]);
```

Now, you can utilize `useMyPersistentCounterStore` similarly to `useState` without specifying an initial value.
Use it just like `useState` without needing an initial value:

```tsx
const [persistedCount, setPersistedCount] = useMyPersistentCounterStore();
```

> For detailed instructions, see [Leveraging Plugins](./md-docs/2.leveraging-plugins.md)
> 🔗 **Learn More**: [Leveraging Plugins](./md-docs/2.leveraging-plugins.md)
## Contributing

See [contributing.md](/contributing.md)
Contributions are welcome! See [contributing.md](/contributing.md).

### 🌟 Don't Forget to Star [this repository](https://github.com/mayank1513/react18-global-store)!

### 🤩 Don't forget to star [this repo](https://github.com/mayank1513/react18-global-store)!
### Hands-On Learning Resources

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)
- [React and Next.js with TypeScript](https://mayank-chaudhari.vercel.app/courses/react-and-next-js-with-typescript)
- [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")
![Repo Stats](https://repobeats.axiom.co/api/embed/ec3e74d795ed805a0fce67c0b64c3f08872e7945.svg "Repobeats Analytics")

## License

This library is licensed under the MPL-2.0 open-source license.
This library is licensed under the **MPL-2.0** open-source license.

> <img src="https://raw.githubusercontent.com/mayank1513/mayank1513/main/popper.png" style="height: 20px"/> Please consider enrolling in [our courses](https://mayank-chaudhari.vercel.app/courses) or [sponsoring](https://github.com/sponsors/mayank1513) our work.
> <img src="https://raw.githubusercontent.com/mayank1513/mayank1513/main/popper.png" style="height: 20px"/> Support our work by [sponsoring](https://github.com/sponsors/mayank1513) or enrolling in [our courses](https://mayank-chaudhari.vercel.app/courses).
<hr />
---

<p align="center" style="text-align:center">with 💖 by <a href="https://mayank-chaudhari.vercel.app" target="_blank">Mayank Kumar Chaudhari</a></p>
<p align="center" style="text-align:center">Made with 💖 by <a href="https://mayank-chaudhari.vercel.app" target="_blank">Mayank Kumar Chaudhari</a></p>
1 change: 1 addition & 0 deletions examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@repo/shared": "workspace:*",
"next": "^15.1.0",
"nextjs-darkmode": "^1.0.7",
"nextjs-darkmode-lite": "^1.0.7",
"nextjs-themes": "^4.0.4",
"r18gs": "workspace:*",
"react": "^19.0.0",
Expand Down
10 changes: 10 additions & 0 deletions lib/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# r18gs

## 2.1.0

### Minor Changes

- 22efc00: Add selectors - do not unnecessarily re-render the components.

### Patch Changes

- a05da0e: Improve type safety

## 2.0.1

### Patch Changes
Expand Down
7 changes: 1 addition & 6 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "r18gs",
"author": "Mayank Kumar Chaudhari <https://mayank-chaudhari.vercel.app>",
"private": false,
"version": "2.0.1",
"version": "2.1.0",
"description": "A simple yet elegant, light weight, react18 global store to replace Zustand for better tree shaking.",
"license": "MPL-2.0",
"main": "./dist/index.js",
Expand Down Expand Up @@ -46,11 +46,6 @@
"@types/react": "16.8 - 19",
"react": "16.8 - 19"
},
"peerDependenciesMeta": {
"next": {
"optional": true
}
},
"funding": [
{
"type": "github",
Expand Down
23 changes: 15 additions & 8 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style -- as ! operator is forbidden by eslint*/
import { createHook, createSetter, createSubcriber, globalRGS } from "./utils";
import { createHook, globalRGS, triggerListeners } from "./utils";

import type { SetStateAction, ValueType } from "./utils";
import type { RGS, SetStateAction, ValueType } from "./utils";

export type { SetterArgType, SetStateAction, Plugin } from "./utils";

Expand All @@ -20,18 +20,25 @@ export type { SetterArgType, SetStateAction, Plugin } from "./utils";
* @param value - Initial value of the store.
* @returns - A tuple (Ordered sequance of values) containing the state and a function to set the state.
*/
const useRGS = <T>(key: string, value?: ValueType<T>): [T, SetStateAction<T>] => {
const useRGS = <T>(
key: string,
value?: ValueType<T>,
...fields: (keyof T)[]
): [T, SetStateAction<T>] => {
/** Initialize the named store when invoked for the first time. */
if (!globalRGS[key])
globalRGS[key] = {
// @ts-expect-error -- ok
v: typeof value === "function" ? value() : value,
v: value instanceof Function ? value() : value,
l: [],
s: createSetter(key),
u: createSubcriber(key),
s: val => {
const rgs = globalRGS[key] as RGS;
const oldV = rgs.v as T;
rgs.v = val instanceof Function ? val(oldV) : val;
triggerListeners(rgs, oldV, rgs.v);
},
};

return createHook<T>(key);
return createHook<T>(key, fields);
};

export { useRGS };
64 changes: 34 additions & 30 deletions lib/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useSyncExternalStore } from "react";

export type Selector = string | number | Symbol;
type Listener = () => void;
type Subscriber = (l: Listener) => () => void;
type ListenerWithSelectors = { l: Listener; s: Selector[] };

export type SetterArgType<T> = T | ((prevState: T) => T);
export type SetStateAction<T> = (value: SetterArgType<T>) => void;
Expand All @@ -10,7 +11,11 @@ export type ValueType<T> = T | (() => T);
/**
* This is a hack to reduce lib size + readability + not encouraging direct access to globalThis
*/
type RGS = { v: unknown; l: Listener[]; s: SetStateAction<unknown> | null; u: Subscriber };
export type RGS = {
v: unknown;
l: ListenerWithSelectors[];
s: SetStateAction<unknown> | null;
};

declare global {
// eslint-disable-next-line no-var -- var required for global declaration.
Expand All @@ -23,34 +28,30 @@ if (!globalThisForBetterMinification.rgs) globalThisForBetterMinification.rgs =
export const globalRGS = globalThisForBetterMinification.rgs;

/** trigger all listeners */
const triggerListeners = (rgs: RGS) => rgs.l.forEach(listener => listener());

/** craete subscriber function to subscribe to the store. */
export const createSubcriber = (key: string): Subscriber => {
return listener => {
const rgs = globalRGS[key] as RGS;
(rgs.l as Listener[]).push(listener);
return () => {
rgs.l = (rgs.l as Listener[]).filter(l => l !== listener);
};
};
};

/** setter function to set the state. */
export const createSetter = <T>(key: string): SetStateAction<unknown> => {
return val => {
const rgs = globalRGS[key] as RGS;
rgs.v = typeof val === "function" ? val(rgs.v as T) : val;
(rgs.l as Listener[]).forEach(listener => listener());
};
export const triggerListeners = <T>(rgs: RGS, oldV: T, newV: T) => {
const updatedFiels: Selector[] = [];
// no need to test this --- it will automatically fail
// if (typeof oldV === "object" && typeof rgs.v === "object")
for (const key in oldV) if (oldV[key] !== newV[key]) updatedFiels.push(key);
rgs.l.forEach(({ l, s }) => (!s.length || s.some(filed => updatedFiels.includes(filed))) && l());
};

/** Extract coomon create hook logic to utils */
export const createHook = <T>(key: string): [T, SetStateAction<T>] => {
export const createHook = <T>(key: string, fields: (keyof T)[]): [T, SetStateAction<T>] => {
const rgs = globalRGS[key] as RGS;
/** This function is called by react to get the current stored value. */
const getSnapshot = () => rgs.v as T;
const val = useSyncExternalStore<T>(rgs.u as Subscriber, getSnapshot, getSnapshot);
const val = useSyncExternalStore<T>(
listener => {
const listenerWithSelectors = { l: listener, s: fields };
rgs.l.push(listenerWithSelectors);
return () => {
rgs.l = rgs.l.filter(l => l !== listenerWithSelectors);
};
},
getSnapshot,
getSnapshot,
);
return [val, rgs.s as SetStateAction<T>];
};

Expand All @@ -67,8 +68,9 @@ const initPlugins = async <T>(key: string, plugins: Plugin<T>[]) => {
const rgs = globalRGS[key] as RGS;
/** Mutate function to update the value */
const mutate: Mutate<T> = newValue => {
const oldValue = rgs.v as T;
rgs.v = newValue;
triggerListeners(rgs);
triggerListeners(rgs, oldValue, newValue);
};
for (const plugin of plugins) {
/** Next plugins initializer will get the new value if updated by previous one */
Expand All @@ -87,24 +89,25 @@ export const initWithPlugins = <T>(
value = value instanceof Function ? value() : value;
if (doNotInit) {
/** You will not have access to the setter until initialized */
globalRGS[key] = { v: value, l: [], s: null, u: createSubcriber(key) };
globalRGS[key] = { v: value, l: [], s: null };
return;
}
/** setter function to set the state. */
const setterWithPlugins: SetStateAction<unknown> = val => {
/** Do not allow mutating the store before all extentions are initialized */
if (!allExtentionsInitialized) return;
const rgs = globalRGS[key] as RGS;
rgs.v = val instanceof Function ? val(rgs.v as T) : val;
triggerListeners(rgs);
const oldValue = rgs.v;
rgs.v = val instanceof Function ? val(oldValue) : val;
triggerListeners(rgs, oldValue, rgs.v);
plugins.forEach(plugin => plugin.onChange?.(key, rgs.v as T));
};

const rgs = globalRGS[key];
if (rgs) {
rgs.v = value;
rgs.s = setterWithPlugins;
} else globalRGS[key] = { v: value, l: [], s: setterWithPlugins, u: createSubcriber(key) };
} else globalRGS[key] = { v: value, l: [], s: setterWithPlugins };
initPlugins(key, plugins);
};

Expand All @@ -130,7 +133,8 @@ export const useRGSWithPlugins = <T>(
value?: ValueType<T>,
plugins?: Plugin<T>[],
doNotInit = false,
...fields: (keyof T)[]
): [T, SetStateAction<T>] => {
if (!globalRGS[key]?.s) initWithPlugins(key, value, plugins, doNotInit);
return createHook<T>(key);
return createHook<T>(key, fields);
};
1 change: 1 addition & 0 deletions lib/tests/declarations.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.module.scss";
Empty file added lib/tests/demo.module.scss
Empty file.
16 changes: 15 additions & 1 deletion lib/tests/use-rgs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, test } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useRGS } from "../src";
import { ChangeEvent, useCallback } from "react";
import { beforeEach } from "node:test";

const COUNT_RGS_KEY = "count";
const TESTID_INPUT = "in1";
Expand All @@ -27,11 +28,24 @@ function Component2() {
return <h1 data-testid={TESTID_DISPLAY}>{count}</h1>;
}

function Component3() {
const [count] = useRGS<number>(COUNT_RGS_KEY + 1, () => 5);
return <div data-testid="t3">{count}</div>;
}

describe("React18GlobalStore", () => {
beforeEach(() => {
cleanup();
// reset RGS store
globalThis.rgs = {};
});
test("check state update to multiple components", async ({ expect }) => {
render(<Component1 />);
render(<Component2 />);
render(<Component3 />);
await act(() => fireEvent.input(screen.getByTestId(TESTID_INPUT), { target: { value: 5 } }));
expect(screen.getByTestId(TESTID_DISPLAY).textContent).toBe("5");
// test function initialization
expect(screen.getByTestId("t3").textContent).toBe("5");
});
});
Loading

0 comments on commit 4ab636e

Please sign in to comment.