When developing React Client Extensions in Liferay, we face unique challenges: multiple decoupled microfrontends, complex shared state, and above all, performance issues derived from unnecessary re-renders. Traditional solutions like Context API or Redux often introduce excessive complexity or overhead that affects user experience.
At JULDITEC we have adopted Zustand as our reference solution for state management in modern architectures with Liferay. In this article we explain why this minimalist library drastically improves the performance of your applications and how to integrate it correctly in your Client Extensions.
What is Zustand and why does it stand out?
Zustand is a state management library for React based on three fundamental principles: simplicity, performance, and absence of boilerplate. Unlike Redux, it doesn't require providers, reducers, or complex actions. Unlike Context API, it doesn't cause cascading re-renders of the entire component hierarchy.
Zustand's philosophy is straightforward: you create a store with a simple function, consume the state through hooks, and the component only re-renders when the specific portions of state it's using change.
Quick comparison: Zustand vs Redux vs Context API
- •Redux: Powerful but verbose. Requires actions, reducers, middleware. Ideal for large applications with complex state logic, but excessive for many cases.
- •Context API: Native to React but with performance issues. Every context change re-renders all consumers, even if they don't use the modified portion.
- •Zustand: Lightweight (less than 1KB), minimalist API, selective subscription by default. The perfect balance for Client Extensions.
The specific problem in Liferay Client Extensions
Client Extensions in Liferay are decoupled frontend applications that integrate into the portal through iframes or as Custom Elements. This architecture presents unique challenges:
- •Decoupled nature: Each Client Extension is an independent application with its own lifecycle.
- •Multiple microfrontends: On the same page, several Client Extensions can coexist that need to share state.
- •Complex shared state: User information, configuration, business data that must be synchronized.
- •Performance issues: Unnecessary re-renders when using Context API or extensive props drilling.
Imagine a dashboard with 5 independent widgets (each one a Client Extension). If you use Context API and update the user state, all widgets re-render even though only one needs that information. With Zustand, only the widget subscribed to that specific data updates.
How to integrate Zustand in a Client Extension
Integrating Zustand into a Client Extension project is surprisingly simple. First, we install the dependency:
npm install zustand
Basic store structure
We create a store specific to our business domain. For example, a user store:
// stores/userStore.js
import { create } from 'zustand';
const useUserStore = create((set) => ({
user: null,
preferences: {},
setUser: (user) => set({ user }),
updatePreferences: (prefs) => set((state) => ({
preferences: { ...state.preferences, ...prefs }
})),
clearUser: () => set({ user: null, preferences: {} })
}));
export default useUserStore;
Usage in components
State consumption is extremely simple and performant:
Not authenticated You can initialize your store with global Liferay data available in The key to Zustand's performance lies in its selector-based selective subscription. When you do: The component only re-renders when Additionally, Zustand has lower overhead than Redux: you don't need complex middleware, there's no action serialization, and the final bundle is significantly smaller (less than 1KB vs ~10KB for Redux). In multi-step forms with validations, Zustand allows managing form state without prop drilling and with surgical re-renders only on modified fields. Each widget (independent Client Extension) can subscribe only to the portion of state it needs: metrics, filters, user configuration, etc. Centralize the state of asynchronous calls, loading states, and errors in a dedicated store: You can expose stores as shared modules between different Client Extensions, allowing state synchronization without complex communication between iframes. Don't create a monolithic store. Divide by responsibilities: If a store grows too large, consider splitting it or using slices (store composition pattern). Zustand supports middleware for advanced functionality: Zustand facilitates testing by being pure functions. You can test store actions without needing to mount components: At JULDITEC we have verified that Zustand is the optimal solution for state management in Liferay Client Extensions. Its combination of simplicity, performance, and absence of boilerplate makes it the perfect tool for decoupled architectures and microfrontends. Performance improvements are measurable and significant: reduction of unnecessary re-renders, lighter bundles, and more maintainable code. If you're developing modern applications on Liferay DXP, Zustand should be in your technology stack. Our approach at JULDITEC always prioritizes performance and simplicity. Zustand fits perfectly into this philosophy, allowing us to deliver robust enterprise solutions without sacrificing development experience or end-user performance. Want to implement high-performance Client Extensions in your Liferay project? Contact us and discover how we can help you build modern and scalable architectures.// components/UserProfile.jsx
import useUserStore from '../stores/userStore';
function UserProfile() {
// Selective subscription: only re-renders if 'user' changes
const user = useUserStore((state) => state.user);
if (!user) return {user.name}
Integration with Liferay globals
window.Liferay:// stores/liferayStore.js
import { create } from 'zustand';
const useLiferayStore = create((set) => ({
themeDisplay: window.Liferay?.ThemeDisplay || {},
currentUser: window.Liferay?.ThemeDisplay?.getUserName() || 'Guest',
languageId: window.Liferay?.ThemeDisplay?.getLanguageId() || 'es_ES'
}));
export default useLiferayStore;
Why it drastically improves performance
const user = useUserStore((state) => state.user);
state.user changes, not when any other store property changes. This contrasts radically with Context API, where any context change causes re-renders of all consumers.Practical comparison: before vs after
With Context API: 15 re-renders in a 5-widget dashboard when updating a single data point.
With Zustand: 1 re-render, only of the widget consuming that specific data.
Real use cases in Client Extensions
1. Complex forms
2. Dashboards with multiple widgets
3. Integration with external APIs
const useApiStore = create((set) => ({
data: null,
loading: false,
error: null,
fetchData: async (endpoint) => {
set({ loading: true, error: null });
try {
const response = await fetch(endpoint);
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
}
}));
4. Independent Client Extensions sharing state
Best practices when using Zustand in Liferay
1. Separate stores by domain
userStore, cartStore, notificationsStore, etc. This improves maintainability and performance.2. Avoid giant stores
3. Using middleware
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
const useStore = create(
devtools(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user })
}),
{ name: 'user-storage' }
)
)
);
4. Testing state
import useUserStore from './userStore';
test('setUser updates the user', () => {
const { setUser, user } = useUserStore.getState();
setUser({ name: 'Juan' });
expect(useUserStore.getState().user.name).toBe('Juan');
});
Conclusion: Zustand as the optimal solution in modern architectures
