An open API service indexing awesome lists of open source software.

https://github.com/afiiif/floppy-disk

Lightweight, simple, and powerful state management library. The alternative for both Zustand & TanStack Query!! 🤯
https://github.com/afiiif/floppy-disk

async global-state-management hooks query react stores

Last synced: 2 months ago
JSON representation

Lightweight, simple, and powerful state management library. The alternative for both Zustand & TanStack Query!! 🤯

Awesome Lists containing this project

README

          

# FloppyDisk.ts 💾

A unified state model for **sync & async** data.

If you know [Zustand](https://zustand.docs.pmnd.rs) & [TanStack-Query](https://tanstack.com/query), you already know FloppyDisk.\
It keeps what works, removes unnecessary complexity, and unifies everything into a simpler API.\
No relearning—just a better experience.

_Smaller bundle. Zero dependencies._

Demo: https://afiiif.github.io/floppy-disk/

**Installation:**

```
npm install floppy-disk
```

## Global Store

Here's how to create and use a store:

```tsx
import { createStore } from "floppy-disk/react";

const useDigimon = createStore({
age: 7,
level: "Rookie",
});
```

You can use the store both inside and outside of React components.

```tsx
function MyDigimon() {
const { age } = useDigimon();
return

Digimon age: {age}
;
// This component will only re-render when `age` changes.
// Changes to `level` will NOT trigger a re-render.
}

function Control() {
return (
<>
{
// You can setState directly
useDigimon.setState((prev) => ({ age: prev.age + 1 }));
}}
>
Increase digimon's age

Evolve
>
);
}

// You can create a custom actions
const evolve = () => {
const { level } = useDigimon.getState();

const order = ["In-Training", "Rookie", "Champion", "Ultimate"];
const nextLevel = order[order.indexOf(level) + 1];

if (!nextLevel) return console.warn("Already at ultimate level");

useDigimon.setState({ level: nextLevel });
};
```

### Store Subscription

At its core, FloppyDisk is a **pub-sub store**.

You can subscribe manually:

```tsx
const unsubscribe = useMyStore.subscribe((state, prev) => {
console.log("New state:", state);
});

// Later
unsubscribe();
```

FloppyDisk provides lifecycle hooks tied to subscription count.

```tsx
const useTowerDefense = createStore(
{ archers: 3, mages: 1, barracks: 2, artillery: 1 },
{
onFirstSubscribe: () => {
console.log("First subscriber! We’re officially popular 🎉");
},
onSubscribe: () => {
console.log("New subscriber joined. Welcome aboard 🫡");
},
onUnsubscribe: () => {
console.log("Subscriber left... was it something I said? 😭");
},
onLastUnsubscribe: () => {
console.log("Everyone left. Guess I’ll just exist quietly now...");
},
},
);
```

### Differences from Zustand

If you're coming from Zustand, this should feel very familiar.\
Key differences:

1. **No Selectors Needed**\
You don't need selectors when using hooks.
FloppyDisk automatically tracks which parts of the state are used and optimizes re-renders accordingly.
2. **Object-Only Store Initialization**\
In FloppyDisk, stores **must** be initialized with an object. Primitive values or function initializers are not allowed.

Zustand examples:

```tsx
const useDate = create(new Date(2021, 01, 11));

const useCounter = create((set) => ({
value: 1,
increment: () => set((prev) => ({ value: prev.value + 1 })),
}));
```

FloppyDisk equivalents:

```tsx
const useDate = createStore({ value: new Date(2021, 01, 11) });

const useCounter = createStore({ value: 1 });
const increment = () => useCounter.setState((prev) => ({ value: prev.value + 1 }));
// Unlike Zustand, defining actions inside the store is **discouraged** in FloppyDisk.
// This improves tree-shakeability and keeps your store minimal.

// However, it's still possible to mix actions with the state if you understand how closures work:
const useCounterAlt = createStore({
value: 1,
increment: () => useCounterAlt.setState((prev) => ({ value: prev.value + 1 })),
});
```

## Async State (Query & Mutation)

FloppyDisk also provides a powerful async state layer, inspired by [TanStack-Query](https://tanstack.com/query) but with a simpler API.

It is agnostic to the type of async operation,
it works with any Promise-based operation—whether it's a network request, local computation, storage access, or something else.

Because of that, we intentionally avoid terms like "fetch" or "refetch".\
Instead, we use:

- **execute** → run the async operation (same as "fetch" in TanStack-Query)
- **revalidate** → re-run while keeping existing data (same as "refetch" in TanStack-Query)

### Query vs Mutation

Query → Read Operations

Queries are designed for reading data.\
They assume:

- no side effects
- no data mutation
- safe to run multiple times

Because of this, queries come with helpful defaults:

- ✅ Retry mechanism (for transient failures)
- ✅ Revalidation (keep data fresh automatically)
- ✅ Caching & staleness control

Use queries when:

- fetching data
- reading from storage
- running idempotent async logic

Mutation → Write Operations

Mutations are designed for changing data.\
Examples:

- insert
- update
- delete
- triggering side effects

Because mutations are **not safe to repeat blindly**, FloppyDisk does **not** include:

- ❌ automatic retry
- ❌ automatic revalidation
- ❌ implicit re-execution

This is intentional.\
Mutations should be explicit and controlled, not automatic.

If you need retry mechanism, then you can always add it manually.

### Single Query

Create a query using `createQuery`:

```tsx
import { createQuery } from "floppy-disk/react";

const myCoolQuery = createQuery(
myAsyncFn,
// { staleTime: 5000, revalidateOnFocus: false } <-- optional options
);

const useMyCoolQuery = myCoolQuery();

// Use it inside your component:

function MyComponent() {
const query = useMyCoolQuery();
if (query.state === "INITIAL") return

Loading...
;
if (query.error) return
Error: {query.error.message}
;
return
{JSON.stringify(query.data)}
;
}
```

### Query State: Two Independent Dimensions

FloppyDisk tracks two things separately:

- Is it running? → `isPending`\
(value: `boolean`)
- What's the result? → `state`\
(value: `INITIAL | 'SUCCESS' | 'ERROR' | 'SUCCESS_BUT_REVALIDATION_ERROR'`)

They are **independent**.

### Automatic Re-render Optimization

Just like the global store, FloppyDisk tracks usage automatically:

```tsx
const { data } = useMyQuery();
// ^Only data changes will trigger a re-render

const value = useMyQuery().data?.foo.bar.baz;
// ^Only data.foo.bar.baz changes will trigger a re-render
```

### Keyed Query (Dynamic Params)

You can create parameterized queries:

```tsx
import { getUserById, type GetUserByIdResponse } from "../utils";

type MyQueryParam = { id: string };

const userQuery = createQuery(
getUserById,
// { staleTime: 5000, revalidateOnFocus: false } <-- optional options
);
```

Use it with parameters:

```tsx
function UserDetail({ id }) {
const useUserQuery = userQuery({ id: 1 });
const query = useUserQuery();
if (query.state === "INITIAL") return

Loading...
;
if (query.error) return
Error: {query.error.message}
;
return
{JSON.stringify(query.data)}
;
}
```

Each unique parameter creates its own cache entry.

### Infinite Query

FloppyDisk does **not provide** a dedicated "infinite query" API.\
Instead, it embraces a simpler and more flexible approach:

> Infinite queries are just **composition** + **recursion**.

Why? Because async state is already powerful enough:

- keyed queries handle parameters
- components handle composition
- recursion handles pagination

No special abstraction needed.

Here is the example on how to implement infinite query properly:

```tsx
type GetPostParams = {
cursor?: string; // For pagination
};
type GetPostsResponse = {
posts: Post[];
meta: { nextCursor: string };
};

const postsQuery = createQuery(getPosts, {
staleTime: Infinity,
revalidateOnFocus: false,
revalidateOnReconnect: false,
});

function Main() {
return ;
}

function Page({ cursor }: { cursor?: string }) {
const usePostsQuery = postsQuery({ cursor });
const { state, data, error } = usePostsQuery();

if (state === "INITIAL") return

Loading...
;
if (error) return
Error
;

return (
<>
{data.posts.map((post) => (

))}
{data.meta.nextCursor && }
>
);
}

function LoadMore({ nextCursor }: { nextCursor?: string }) {
const [isNextPageRequested, setIsNextPageRequested] = useState(() => {
const stateOfNextPageQuery = postsQuery({ cursor: nextCursor }).getState();
return stateOfNextPageQuery.isPending || stateOfNextPageQuery.isSuccess;
});

if (isNextPageRequested) {
return ;
}

return setIsNextPageRequested(true)} />;
}
```

When implementing infinite queries, it is **highly recommended to disable automatic revalidation**.

Why?\
In an infinite list, users may scroll through many pages ("_doom-scrolling_").\
If revalidation is triggered:

- All previously loaded pages may re-execute
- Content at the top may change without the user noticing
- Layout shifts can occur unexpectedly

This leads to a **confusing and unstable user experience**.\
Revalidating dozens of previously viewed pages rarely provides value to the user.

## SSR Guidance

Examples for using stores and queries in SSR with isolated data (no shared state between users).

### Initialize Store State from Server

```tsx
const useCountStore = createStore({ count: 0 });

function Page({ initialCount }) {
const { count } = useCountStore({
initialState: { count: initialCount }, // e.g. 3
});

return <>count is {count}>; // Output: count is 3
}
```

### Initialize Query Data from Server

```tsx
async function MyServerComponent() {
const data = await getData(); // e.g. { count: 3 }
return ;
}

const myQuery = createQuery(getData);
const useMyQuery = myQuery();

function MyClientComponent({ initialData }) {
const { data } = useMyQuery({
initialData: initialData,
// initialDataIsStale: true <-- Optional, default to false (no immediate revalidation)
});

return <>count is {data.count}>; // Output: count is 3
}
```