Skip links

Taming the State Beast: How We Built TeamUnity with Jotai and NextJS

The State of State Management: A Developer’s Confession

Let’s be honest—state management has always been the thorny crown of modern web development. At Sweesh.dev, we’ve wrestled with Redux’s boilerplate, Context API’s re-render issues, and the general complexity that comes with keeping UI in sync with data. When we embarked on building TeamUnity, our Kanban-style project management platform, we knew state management would make or break the user experience.

“We need something lightweight but powerful,” our lead developer said during our initial architecture discussions. “Something that won’t force us to write reducers and actions for every little UI change.” That’s when Jotai entered our technical radar.

We’d been tracking Jotai’s development for a while—this atomic approach to state management promised the granularity we needed without Redux’s ceremony. For TeamUnity, where cards need to move fluidly between columns and updates need to propagate instantly, Jotai’s atom-based model seemed like the perfect fit.

Our tech stack decision came down to NextJS for the framework, Prisma for type-safe database access, Supabase for authentication and real-time features, Zod for validation, and Jotai as the state management solution. This combination gave us the modern foundation we needed while keeping bundle size reasonable—critical for a responsive project management tool.

What really sold us on Jotai was its composability. Unlike Redux where everything funnels through a single store, Jotai lets us compose atoms together, creating derived state that updates automatically when its dependencies change. This pattern aligned perfectly with our Kanban board’s hierarchical nature: boards contain columns, columns contain cards, and changes at any level need to propagate efficiently.

The Anatomy of a Drag: Technical Deep Dive

The heart of any Kanban board is the drag-and-drop functionality. It seems simple on the surface—grab a card, move it to another column—but the state management underneath is surprisingly complex.

Here’s how we architected it with Jotai:

// Define our base atoms
const boardAtom = atom({
  columns: {},
  cards: {},
  order: {}
});

// Derived atoms for specific views
const columnCardsAtom = atomFamily((columnId) => 
  atom((get) => {
    const board = get(boardAtom);
    const columnCardIds = board.order[columnId] || [];
    return columnCardIds.map(id => board.cards[id]);
  })
);

// Atom for drag state
const dragStateAtom = atom({
  isDragging: false,
  cardId: null,
  sourceColumn: null,
  targetColumn: null
});

This approach gave us fine-grained control over updates. When a user drags a card, we only update the specific atoms that need to change, rather than triggering a full board re-render. The performance difference was immediately noticeable, especially on boards with dozens of cards.

The real magic happened in our drag handlers:

const handleDragEnd = useCallback((result) => {
  if (!result.destination) return;
  
  setDragState({
    isDragging: false,
    cardId: null,
    sourceColumn: null,
    targetColumn: null
  });
  
  const { source, destination, draggableId } = result;
  
  // Optimistic UI update
  updateBoardOrder({
    cardId: draggableId,
    sourceColumnId: source.droppableId,
    targetColumnId: destination.droppableId,
    sourceIndex: source.index,
    targetIndex: destination.index
  });
  
  // Persist to database
  saveCardPosition({
    cardId: draggableId,
    columnId: destination.droppableId,
    position: destination.index
  });
}, [setDragState, updateBoardOrder, saveCardPosition]);

We implemented optimistic UI updates—the card moves instantly in the interface, while the database update happens asynchronously. If the save fails, we revert the state change, providing a seamless experience without waiting for server responses.

One particularly challenging aspect was handling the visual feedback during dragging. We needed to show a ghost card in the potential drop location while maintaining the original card’s position until the drop was confirmed. Jotai’s fine-grained reactivity made this possible without the performance penalties we’d seen in other state management solutions:

const isDraggingOverAtom = atomFamily((columnId) =>
  atom((get) => {
    const dragState = get(dragStateAtom);
    return dragState.isDragging && dragState.targetColumn === columnId;
  })
);

// In our column component
const isDraggingOver = useAtomValue(isDraggingOverAtom(columnId));

This pattern allowed us to update just the visual state of the target column without re-rendering the entire board.

Data Flow Diagrams: From User Action to Database

The data flow in TeamUnity follows a clear pattern that leverages the strengths of each technology in our stack:

  1. User initiates an action (e.g., dragging a card)
  2. Jotai atoms update immediately for responsive UI
  3. NextJS API route receives the change request
  4. Prisma handles the database transaction
  5. Supabase real-time channels broadcast the change to other users
  6. Other clients receive the update and their Jotai atoms sync accordingly

This architecture gave us several advantages. First, the UI remains responsive regardless of network conditions. Second, we could implement collaborative features where multiple users see changes in real-time. Third, we maintained data integrity through Prisma’s transaction support.

State management in such apps is tricky

One challenge we faced was synchronizing state across multiple clients. When two users move cards simultaneously, conflict resolution becomes necessary. We implemented a last-write-wins strategy with timestamp tracking:

// In our Supabase subscription handler
supabase
  .channel('board-updates')
  .on('postgres_changes', { 
    event: 'UPDATE', 
    schema: 'public', 
    table: 'cards' 
  }, (payload) => {
    const { new: newCard, old: oldCard } = payload;
    
    // Only update if the incoming change is newer
    if (newCard.updated_at > get(cardLastUpdatedAtom(newCard.id))) {
      setCardAtom(newCard.id, newCard);
      setCardLastUpdatedAtom(newCard.id, newCard.updated_at);
    }
  })
  .subscribe();

The integration between Prisma and Supabase deserves special mention. While Prisma gave us type-safe database access with a beautiful API, Supabase’s real-time capabilities were essential for the collaborative nature of TeamUnity. We created a thin abstraction layer that used Prisma for all write operations while leveraging Supabase subscriptions for real-time updates:

// Database write with Prisma
async function updateCard(cardId, data) {
  const updatedCard = await prisma.card.update({
    where: { id: cardId },
    data: {
      ...data,
      updated_at: new Date()
    }
  });
  
  return updatedCard;
}

// Real-time subscription with Supabase
function subscribeToCardUpdates(callback) {
  return supabase
    .channel('public:cards')
    .on('postgres_changes', {
      event: '*',
      schema: 'public',
      table: 'cards'
    }, callback)
    .subscribe();
}

This hybrid approach gave us the best of both worlds: Prisma’s type safety and query capabilities with Supabase’s real-time features.

The Authentication Puzzle

Authentication in modern web apps requires balancing security, user experience, and developer productivity. Supabase gave us a solid foundation, but integrating it with our NextJS and Jotai setup required some creative solutions.

We used Zod to validate form inputs before they ever reached our authentication logic:

const loginSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters')
});

// In our login component
const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(loginSchema)
});

For state persistence, we created a global user atom that components could subscribe to:

const userAtom = atom(null);
const isAuthenticatedAtom = atom(
  (get) => get(userAtom) !== null
);

// Custom hook for auth state
function useAuth() {
  const [user, setUser] = useAtom(userAtom);
  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
  
  // ... auth methods
  
  return {
    user,
    isAuthenticated,
    login,
    logout,
    signup
  };
}

This approach gave us a clean API for components to access authentication state without prop drilling or context providers.

A particularly elegant pattern emerged when we needed to handle authenticated routes. By combining NextJS middleware with our Jotai auth atoms, we created a seamless authentication flow:

// In our middleware.js
export function middleware(req) {
  const { pathname } = req.nextUrl;
  
  // Check if the path is a protected route
  if (
    pathname.startsWith('/app') &&
    !req.cookies.has('sb-auth-token')
  ) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  
  return NextResponse.next();
}

// In our _app.js
function MyApp({ Component, pageProps }) {
  const { user, loading } = useAuth();
  
  useEffect(() => {
    // Sync auth state with Jotai on app initialization
    const { data: authListener } = supabase.auth.onAuthStateChange(
      (event, session) => {
        if (session?.user) {
          setUserAtom(session.user);
        } else {
          setUserAtom(null);
        }
      }
    );
    
    return () => authListener.unsubscribe();
  }, []);
  
  return <Component {...pageProps} />;
}

When Things Went Wrong: Lessons in Debugging

Not everything was smooth sailing. We hit a significant challenge with state synchronization when cards were moved rapidly between columns. The issue manifested as cards occasionally duplicating or disappearing—the kind of bug that keeps developers up at night.

The root cause? Jotai’s atomic updates were happening faster than our debounced database saves, creating race conditions. We solved this by implementing a queue system for state changes:

const stateChangeQueue = atom([]);
const isProcessingQueueAtom = atom(false);

// Process queue one item at a time
const processQueueAtom = atom(
  null,
  async (get, set) => {
    if (get(isProcessingQueueAtom)) return;
    
    set(isProcessingQueueAtom, true);
    
    while (get(stateChangeQueue).length > 0) {
      const [firstChange, ...restChanges] = get(stateChangeQueue);
      set(stateChangeQueue, restChanges);
      
      try {
        await saveChange(firstChange);
      } catch (error) {
        console.error('Failed to save change', error);
        // Revert optimistic update
        set(revertChangeAtom, firstChange);
      }
    }
    
    set(isProcessingQueueAtom, false);
  }
);

This pattern ensured changes were processed sequentially, eliminating the race conditions. It was a valuable lesson in the challenges of real-time collaborative interfaces.

Another issue we encountered was with memory usage. As users interacted with large boards, we noticed the application’s memory footprint growing. The culprit? We were creating new atom instances on each render in some components. The fix was to move atom creation outside component definitions and use atomFamily for parameterized state:

// Before: Creating new atoms on each render (bad)
function ColumnComponent({ columnId }) {
  // This creates a new atom on every render!
  const columnAtom = atom((get) => get(boardAtom).columns[columnId]);
  const [column] = useAtom(columnAtom);
  // ...
}

// After: Using atomFamily (good)
const columnAtomFamily = atomFamily((columnId) => 
  atom((get) => get(boardAtom).columns[columnId])
);

function ColumnComponent({ columnId }) {
  // This reuses the same atom for a given columnId
  const [column] = useAtom(columnAtomFamily(columnId));
  // ...
}

This pattern significantly reduced memory usage and improved performance on large boards.

Beyond the Kanban: Business Impact

The technical architecture we’ve described translated directly to business value for our clients. Teams using TeamUnity reported:

  • 32% reduction in time spent updating project status
  • Near-instant synchronization across team members
  • Improved visibility into project bottlenecks
  • Seamless integration with existing workflows

One client, a software agency managing 15 concurrent projects, told us: “The responsiveness of the interface makes all the difference. With our previous tool, there was always a lag when moving cards that disrupted our workflow.”

Login panel for TaskFlow / TeamUnity

The performance benefits of our Jotai implementation were particularly noticeable on large boards. While competing solutions would slow down with 50+ cards, TeamUnity maintained its snappy response times thanks to the granular state updates.

From a business perspective, the technical decisions we made had direct revenue implications. The improved performance and user experience led to higher user retention rates compared to industry averages. Our telemetry showed that users were spending more time in the application and completing more actions per session—key indicators of product stickiness.

The real-time collaboration features, powered by our Supabase integration, became a major selling point. Teams reported fewer miscommunications and reduced need for status meetings, as everyone could see project progress in real time. One product manager told us they had eliminated their daily standup meetings entirely, saving the team five hours per week.

The Future Stack: Evolution of Our Approach

Building TeamUnity has solidified our confidence in Jotai as a state management solution, but we’re not standing still. We’re already planning enhancements:

  1. Implementing selective hydration patterns to further improve initial load performance
  2. Exploring Jotai’s integration with React Suspense for more elegant loading states
  3. Building a more robust offline mode using a combination of Jotai persistence and IndexedDB
  4. Expanding our use of derived atoms to create more sophisticated board analytics

The combination of NextJS, Jotai, Prisma, and Supabase has proven to be remarkably productive and performant. For teams building complex, interactive web applications with real-time features, we believe this stack offers the right balance of developer experience and end-user performance.

At Sweesh.dev, we’re continuing to refine our approach to state management. The lessons from TeamUnity—particularly around atomic updates, optimistic UI, and synchronization challenges—have influenced how we architect all our applications. As we often tell our clients: in modern web development, your application is only as good as your state management strategy.

Looking ahead, we’re particularly excited about the evolution of Jotai’s ecosystem. The library’s focus on composability and its compatibility with React 18 features position it well for future applications. We’re already experimenting with Jotai’s integration with React Server Components in NextJS 13, which promises to further optimize our client-server data flow.

For teams considering a similar stack, our advice is clear: embrace atomic state management, invest in type safety throughout your stack, and build with real-time collaboration in mind from day one. The combination of these approaches has allowed us to deliver a product that not only meets but exceeds user expectations in the competitive project management space.

🍪 This website uses cookies to improve your web experience.