When I first encountered isPending in Solid 2.0, I thought it was just a loading indicator. A few weeks later I was using it to replace state I would’ve normally written by hand.
I took the leap and went all in on trying to learn this primitive. Solid 2.0 has a relatively small set of primitives, but each one is surprisingly expressive once you understand its mental model.
So what is it? The best short definition I’ve found is:
isPending(fn) performs the read you give it and reports whether anything it touched has unsettled async behind it.
The mistake I kept making was thinking about pending in terms of operations. Eventually I realized isPending doesn’t care about operations at all. It cares about reads.
This isn’t a normal intro to isPending article. I want you to see how my thinking has evolved over time.
isPending is just ok
So this is where I started. My initial mental model was that it would tell you if a value was being held back by a transition. In Solid 2.0 everything is a transition when async is involved.
One easy example of this is something like pagination. You start out creating a signal to store the page you’re on.
import { createSignal } from "solid-js";
const [page, setPage] = createSignal(1);
const nextPage = () => {
setPage(page => page + 1);
};
then you create the JSX which calls your nextPage handler.
<p>Page: {page()}</p>
<button onClick={nextPage}>Next →</button>
Simple enough, you hit next and the page number goes up right away. Things change when we attach some async to this signal.
import { createSignal, createMemo, For } from "solid-js";
+ const PAGE_SIZE = 4
+ async function searchBooks(q: string, page: number) {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ const matches = BOOKS.filter( b => b.title.toLowerCase().includes(q));
+ return matches.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
+ }
export default function App() {
const [page, setPage] = createSignal(1);
+ const books = createMemo(() => searchBooks('', page()))
const nextPage = () => {
setPage(page => page + 1);
};
return (
<>
+ <For each={books()}>
+ {book => (
+ <div>
+ <h3>{book.title}</h3>
+ </div>
+ )}
+ </For>
<p>Page: {page()}</p>
<button onClick={nextPage}>Next →</button>
</>
)
}
Now when clicking next our page signal only flips to 2 once the searchBooks is done loading. Solid has automatically created a transition for us.
This is pretty bad UX on the surface, but we can improve it by fading out the list with an isPending check. Here is the question though. What fn do we pass to isPending?
Would it be the books memo, or the page signal? Turns out at this very moment we can actually use either. One problem I ran into a lot early on was pending states flickering when I didn’t want them to. This was caused by my pending states being too broad in what they covered.
Pending is oblivious to the reason it went pending so you want to make sure you read the right thing in it.
At some point down the line we might end up doing refresh(books), so we should think do we want the list to dim in this case too, or just when the page changes? I think in this case it would be fine.
+ import { isPending } from "solid-js";
+ <div style={{opacity: isPending(books) ? 0.5: 1}}>
<For each={books()}>
{book => (
<div>
<h3>{book.title}</h3>
</div>
)}
</For>
+ </div>
This is the basic use case for pending that everyone thinks of, but there’s a few gotchas I ran into along the way.
isPending is not very useful
So this all works great, but as the app I was building grew and I started using actions for mutating data, I became frustrated. My pending indicators would flicker when I wasn’t expecting them to.
Let’s show a simple example of what I mean. Let’s use an action plus createOptimisticStore to add a book when clicking a button.
import { action, createOptimisticStore, refresh } from "solid-js";
const [books, setBooks] = createOptimisticStore(
() => searchBooks("", page()),
[]
);
const addBook = action(function* () {
yield addBookOnServer();
refresh(books);
});
// JSX
<p>
<button onClick={addBook}>Add a book</button>
</p>;
Since we are using a store we need to change our For to just use books instead of books(). But what about our isPending? It needs to be a function, hmmm. Maybe we can do isPending(() => books) but that doesn’t quite work as expected. This is another key point of isPending with stores. You must subscribe to a “leaf” node of the store and be more specific. We can actually tailor our loading indicator to a few different scenarios.
isPending(() => books.length); // subscribe to only creates and deletes
isPending(() => books[0].title); // subscribe only if the first books title changes
In this case let’s use books.length
So this kind of does what we want. We’ve restored the grey out when hitting next, but if you add a book it is kinda weird. At first you see no indicator, and then after some time the list dims unexpectedly.
This is where you need to remember that books only goes pending during the refresh after the mutation.
So we could take two approaches here. One is adding loading indicators while we wait for the new book to show up, or we could use optimistic state to show something right away. The latter being preferred.
Modify our addBook action to the following:
const addBook = action(function* () {
setBooks(s => {
s.unshift({
id: BOOKS.length + 1,
title: "The Solid Way",
author: "S. Ignal",
year: 2026,
});
});
yield addBookOnServer();
refresh(books);
});
Now our book will show up immediately, but we run into another issue. Greying out the books when adding doesn’t really make sense anymore. So we actually made a mistake tying our isPending to books. We actually just want it tied to the page changing.
<div style={{ opacity: isPending(() => page()) ? 0.5 : 1 }}>...</div>
isPending is pretty cool after all
Because of all these issues I was starting to get a bit frustrated with isPending at this point. Then I had an idea to use optimistic state for everything. Let’s say we want to change the “Add a book” button to say “Adding a book” while the action is in flight. How would we do that?
My first inclination was to not use isPending and just add another boolean optimistic state.
import { createOptimistic, action, refresh } from "solid-js";
export default function App() {
+ const [adding, setAdding] = createOptimistic(false);
const addBook = action(function* () {
+ setAdding(true);
setBooks(s => {
s.unshift({
id: BOOKS.length + 1,
title: "The Solid Way",
author: "S. Ignal",
year: 2026,
});
});
yield addBookOnServer();
refresh(books);
});
return (
<p>
<button onClick={addBook}>
+ {adding() ? 'Adding' : 'Add'} a book
</button>
</p>
)
}
And this actually works pretty well. If you know anything about optimistic state it reverts at the end of an action, so it only stays true temporarily.
Then I had a realization. We already have optimistic state in books. Why do we need a separate createOptimistic. Can I take isPending to the extreme and use it for this too?!
Turns out we can by using what we learnt earlier.
<button onClick={addBook}>
{isPending(() => books.length) ? "Adding" : "Add"} a book
</button>
So we can use isPending to check if a very specific piece of optimistic state is modified without needing another boolean. Pretty cool.
isPending is mind blowing!
Once I figured this out, I started wondering how far the idea could go.
Could I get per row pending on very specific store properties. Could I do things like: isPending(() => book.title); and have it trigger only when the books title changed?
Once again, it turns out we can but with one additional primitive.
Let’s add this action:
const editBookTitle = action(function* (id: number) {
setBooks(s => {
const book = s.find(b => b.id === id);
if (book) book.title = "The Solid Way (2nd ed.)";
});
yield new Promise(resolve => setTimeout(resolve, 1000));
refresh(books);
});
In this case we aren’t going to worry about actually saving the title. We are more worried about the pending state.
By your title add:
<h3>
{book.title}
{isPending(() => book.title) && "Pending..."}
<button onClick={() => editBookTitle(book.id)}>Edit</button>
</h3>
Simple right? but we’ve made a mess of things. Our pending indicator lights up, but when we refresh(books); we actually light up every row. We can avoid this by using another primitive called latest.
This syntax definitely feels a bit verbose and clunky but it works. Try doing this:
import { latest } from "solid-js";
<span>{isPending(() => latest(() => book.title)) && "Pending..."}</span>;
it now works as we want. latest is not “the previous value” — during an optimistic write it returns the prediction (the override wins by design). That’s precisely why the pending check still fires for genuine edits while ignoring the refresh. In other words, latest makes the check ask “do I have an unconfirmed edit on this value?” instead of “is anything reloading it?”
Unfortunately our adding book is also triggering. Let’s fix that using this technique as well.
<button onClick={addBook}>
{isPending(() => latest(() => books.length)) ? "Adding" : "Add"} a book
</button>
The pattern that finally clicked for me was this: don’t ask “what operation is running?” Ask “what piece of state would be different if that operation completed?” Then point isPending at that.
Conclusion
I started this journey thinking isPending was a simple loading flag, spent a week convinced it was more trouble than it was worth, and came out the other side using it for things I would normally have written extra state for.
If I had to compress everything into a few rules, it would be these:
- isPending tells you something is unsettled, never why.
- Optimistic state is often the loading state.
- latest filters out background refresh noise.
That’s the untapped potential I mentioned at the start. We’re used to loading states being something you manage. In Solid 2.0 they’re something you read.