Skip to content

afiiif/floppy-disk

Repository files navigation

FloppyDisk.ts πŸ’Ύ

A lightweight, simple, and powerful state management library.

This library was highly-inspired by Zustand and TanStack-Query, they're awesome state manager. FloppyDisk provides a very similar developer experience (DX), while introducing additional features and a smaller bundle size.

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

Installation:

npm install floppy-disk

Global Store

Here's how to create and use a store:

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.

function MyDigimon() {
  const { age } = useDigimon();
  return <div>Digimon age: {age}</div>;
  // This component will only re-render when `age` changes.
  // Changes to `level` will NOT trigger a re-render.
}

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

      <button onClick={evolve}>Evolve</button>
    </>
  );
}

// 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:

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

// Later
unsubscribe();

FloppyDisk provides lifecycle hooks tied to subscription count.

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:

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

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

FloppyDisk equivalents:

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 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:

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 <div>Loading...</div>;
  if (query.error) return <div>Error: {query.error.message}</div>;
  return <div>{JSON.stringify(query.data)}</div>;
}

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:

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:

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

type MyQueryParam = { id: string };

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

Use it with parameters:

function UserDetail({ id }) {
  const useUserQuery = userQuery({ id: 1 });
  const query = useUserQuery();
  if (query.state === "INITIAL") return <div>Loading...</div>;
  if (query.error) return <div>Error: {query.error.message}</div>;
  return <div>{JSON.stringify(query.data)}</div>;
}

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:

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

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

function Main() {
  return <Page cursor={undefined} />;
}

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

  if (state === "INITIAL") return <div>Loading...</div>;
  if (error) return <div>Error</div>;

  return (
    <>
      {data.posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      {data.meta.nextCursor && <LoadMore nextCursor={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 <Page cursor={nextCursor} />;
  }

  return <BottomObserver onReachBottom={() => 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

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

async function MyServerComponent() {
  const data = await getData(); // e.g. { count: 3 }
  return <MyClientComponent initialData={data} />;
}

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
}

About

Lightweight, simple, and powerful state management library. The alternative for both Zustand & ReactQuery!! 🀯

Topics

Resources

License

Stars

Watchers

Forks

Contributors