Skip to main content

State Management in UI5: Models, Patterns, and Best Practices

State management is the backbone of any complex UI application. In SAP UI5, managing application state effectively determines whether your Fiori app is maintainable and scalable, or becomes a tangled mess of scattered data and unpredictable behavior.

This comprehensive guide explores state management strategies in UI5, from built-in models to advanced patterns inspired by modern frameworks like Redux.

Understanding State in UI5 Applications

What is Application State?

State includes:

  • UI State – Which tabs are open, what's selected, loading indicators
  • Data State – Business data from backend services
  • Session State – User preferences, authentication tokens
  • Transient State – Form inputs, validation errors

The Challenge

// Without proper state management - scattered data
var oController = {
  _selectedCustomer: null,
  _isLoading: false,
  _filterValue: "",
  _sortOrder: "asc",
  // Data everywhere, hard to track changes
};

Approach 1: JSON Model (Simple State)

When to Use

  • Client-side data management
  • View-specific state
  • Form data before submission
  • UI control states

Basic Implementation

// In Component.js
import JSONModel from "sap/ui/model/json/JSONModel";

onInit: function() {
  // Create application state model
  var oAppState = new JSONModel({
    ui: {
      busy: false,
      selectedTab: "customers",
      sidebarCollapsed: false
    },
    filters: {
      searchQuery: "",
      dateRange: { from: null, to: null },
      status: []
    },
    data: {
      customers: [],
      selectedCustomer: null,
      orders: []
    },
    user: {
      name: "",
      role: "",
      preferences: {}
    }
  });
  
  this.setModel(oAppState, "appState");
}

Accessing State in Controller

onCustomerSelect: function(oEvent) {
  var oModel = this.getModel("appState");
  var oContext = oEvent.getParameter("listItem").getBindingContext("appState");
  var oCustomer = oContext.getObject();
  
  // Update state
  oModel.setProperty("/data/selectedCustomer", oCustomer);
  oModel.setProperty("/ui/selectedTab", "details");
},

getBusyState: function() {
  var oModel = this.getModel("appState");
  return oModel.getProperty("/ui/busy");
},

setBusyState: function(bBusy) {
  var oModel = this.getModel("appState");
  oModel.setProperty("/ui/busy", bBusy);
}

Two-Way Binding in View

<Input
  value="{appState>/filters/searchQuery}"
  liveChange=".onSearchChange"/>

<List
  items="{appState>/data/customers}"
  mode="SingleSelectMaster">
  <StandardListItem
    title="{appState>name}"
    description="{appState>email}"/>
</List>

<Panel visible="{appState>/data/selectedCustomer}">
  <Title text="{appState>/data/selectedCustomer/name}"/>
  <Text text="{appState>/data/selectedCustomer/email}"/>
</Panel>

Approach 2: OData Model (Backend State)

When to Use

  • SAP backend data
  • CRUD operations
  • Real-time data synchronization
  • Complex queries and filters

V2 Model Configuration

// In manifest.json
"models": {
  "": {
    "type": "sap.ui.model.odata.v2.ODataModel",
    "settings": {
      "defaultOperationMode": "Server",
      "defaultBindingMode": "TwoWay",
      "defaultCountMode": "Inline",
      "useBatch": true,
      "refreshAfterChange": false
    },
    "dataSource": "mainService",
    "preload": true
  }
}

// In Component.js
onInit: function() {
  var oModel = this.getModel();
  
  // Set size limit for client-side operations
  oModel.setSizeLimit(1000);
  
  // Handle metadata loaded
  oModel.metadataLoaded().then(function() {
    console.log("Metadata loaded successfully");
  });
}

Reading Data

// Method 1: List Binding
<Table items="{/Customers}">
  <columns>
    <Column><Text text="Name"/></Column>
    <Column><Text text="Email"/></Column>
  </columns>
  <items>
    <ColumnListItem>
      <cells>
        <Text text="{Name}"/>
        <Text text="{Email}"/>
      </cells>
    </ColumnListItem>
  </items>
</Table>

// Method 2: Programmatic Read
onLoadCustomers: function() {
  var oModel = this.getModel();
  var that = this;
  
  this.setBusy(true);
  
  oModel.read("/Customers", {
    filters: [
      new Filter("Status", FilterOperator.EQ, "Active")
    ],
    urlParameters: {
      "$expand": "Orders",
      "$top": 50
    },
    success: function(oData) {
      var aCustomers = oData.results;
      // Process data
      that.getModel("view").setProperty("/customers", aCustomers);
      that.setBusy(false);
    },
    error: function(oError) {
      MessageBox.error("Failed to load customers");
      that.setBusy(false);
    }
  });
}

Creating and Updating

onCreate: function() {
  var oModel = this.getModel();
  var oNewCustomer = {
    Name: "John Doe",
    Email: "john@example.com",
    Status: "Active"
  };
  
  oModel.create("/Customers", oNewCustomer, {
    success: function(oData) {
      MessageToast.show("Customer created");
    },
    error: function(oError) {
      MessageBox.error("Creation failed");
    }
  });
},

onUpdate: function(sPath, oUpdatedData) {
  var oModel = this.getModel();
  
  oModel.update(sPath, oUpdatedData, {
    success: function() {
      MessageToast.show("Updated successfully");
    },
    error: function() {
      MessageBox.error("Update failed");
    }
  });
}

Batch Operations

onBatchUpdate: function() {
  var oModel = this.getModel();
  
  // Defer changes
  oModel.setDeferredGroups(["batchGroup"]);
  
  // Queue multiple changes
  oModel.update("/Customers('1')", { Status: "Inactive" }, {
    groupId: "batchGroup"
  });
  oModel.update("/Customers('2')", { Status: "Inactive" }, {
    groupId: "batchGroup"
  });
  oModel.create("/Orders", { CustomerId: "1", Amount: 500 }, {
    groupId: "batchGroup"
  });
  
  // Submit all at once
  oModel.submitChanges({
    groupId: "batchGroup",
    success: function(oData) {
      MessageToast.show("Batch update successful");
    },
    error: function(oError) {
      MessageBox.error("Batch update failed");
    }
  });
}

Approach 3: Redux-like State Management

Why Redux Pattern in UI5?

  • Predictable state – Single source of truth
  • Testable – Pure functions, easy to test
  • Time-travel debugging – Track all state changes
  • Large applications – Scales better than scattered models

Store Implementation

// store/Store.js
sap.ui.define([
  "sap/ui/base/Object"
], function(BaseObject) {
  "use strict";

  return BaseObject.extend("com.mycompany.store.Store", {
    constructor: function() {
      this._state = {};
      this._listeners = [];
      this._reducers = {};
    },

    registerReducer: function(sKey, fnReducer) {
      this._reducers[sKey] = fnReducer;
    },

    getState: function() {
      return Object.assign({}, this._state);
    },

    dispatch: function(oAction) {
      console.log("Action:", oAction.type, oAction.payload);
      
      var oNewState = {};
      
      // Run all reducers
      for (var sKey in this._reducers) {
        oNewState[sKey] = this._reducers[sKey](
          this._state[sKey],
          oAction
        );
      }
      
      this._state = oNewState;
      
      // Notify listeners
      this._listeners.forEach(function(fnListener) {
        fnListener(this._state);
      }.bind(this));
      
      return oAction;
    },

    subscribe: function(fnListener) {
      this._listeners.push(fnListener);
      
      // Return unsubscribe function
      return function() {
        var iIndex = this._listeners.indexOf(fnListener);
        if (iIndex > -1) {
          this._listeners.splice(iIndex, 1);
        }
      }.bind(this);
    }
  });
});

Reducers

// reducers/customerReducer.js
sap.ui.define([], function() {
  "use strict";

  var initialState = {
    customers: [],
    selectedCustomer: null,
    loading: false,
    error: null
  };

  return function customerReducer(state, action) {
    state = state || initialState;
    
    switch (action.type) {
      case "CUSTOMERS_LOAD_START":
        return Object.assign({}, state, {
          loading: true,
          error: null
        });
        
      case "CUSTOMERS_LOAD_SUCCESS":
        return Object.assign({}, state, {
          customers: action.payload,
          loading: false
        });
        
      case "CUSTOMERS_LOAD_ERROR":
        return Object.assign({}, state, {
          loading: false,
          error: action.payload
        });
        
      case "CUSTOMER_SELECT":
        return Object.assign({}, state, {
          selectedCustomer: action.payload
        });
        
      case "CUSTOMER_DESELECT":
        return Object.assign({}, state, {
          selectedCustomer: null
        });
        
      default:
        return state;
    }
  };
});

Actions

// actions/customerActions.js
sap.ui.define([], function() {
  "use strict";

  return {
    loadCustomers: function(oStore, oModel) {
      oStore.dispatch({ type: "CUSTOMERS_LOAD_START" });
      
      oModel.read("/Customers", {
        success: function(oData) {
          oStore.dispatch({
            type: "CUSTOMERS_LOAD_SUCCESS",
            payload: oData.results
          });
        },
        error: function(oError) {
          oStore.dispatch({
            type: "CUSTOMERS_LOAD_ERROR",
            payload: oError.message
          });
        }
      });
    },

    selectCustomer: function(oStore, oCustomer) {
      oStore.dispatch({
        type: "CUSTOMER_SELECT",
        payload: oCustomer
      });
    },

    deselectCustomer: function(oStore) {
      oStore.dispatch({ type: "CUSTOMER_DESELECT" });
    }
  };
});

Component Integration

// Component.js
import Store from "./store/Store";
import customerReducer from "./reducers/customerReducer";
import orderReducer from "./reducers/orderReducer";
import JSONModel from "sap/ui/model/json/JSONModel";

onInit: function() {
  // Initialize store
  this._store = new Store();
  
  // Register reducers
  this._store.registerReducer("customers", customerReducer);
  this._store.registerReducer("orders", orderReducer);
  
  // Create view model
  var oViewModel = new JSONModel(this._store.getState());
  this.setModel(oViewModel, "view");
  
  // Subscribe to store changes
  this._store.subscribe(function(oState) {
    oViewModel.setData(oState);
  });
},

getStore: function() {
  return this._store;
}

Controller Usage

import customerActions from "../actions/customerActions";

onInit: function() {
  this._store = this.getOwnerComponent().getStore();
},

onLoadPress: function() {
  var oModel = this.getModel();
  customerActions.loadCustomers(this._store, oModel);
},

onCustomerSelect: function(oEvent) {
  var oContext = oEvent.getParameter("listItem").getBindingContext("view");
  var oCustomer = oContext.getObject();
  customerActions.selectCustomer(this._store, oCustomer);
}

Advanced Patterns

Middleware (Side Effects)

// middleware/loggerMiddleware.js
function loggerMiddleware(oStore) {
  return function(next) {
    return function(oAction) {
      console.group(oAction.type);
      console.log("Previous State:", oStore.getState());
      console.log("Action:", oAction);
      
      var result = next(oAction);
      
      console.log("Next State:", oStore.getState());
      console.groupEnd();
      
      return result;
    };
  };
}

// Apply middleware
oStore.applyMiddleware(loggerMiddleware, thunkMiddleware);

Async Actions with Thunk

// Thunk middleware
function thunkMiddleware(oStore) {
  return function(next) {
    return function(oAction) {
      if (typeof oAction === "function") {
        return oAction(oStore.dispatch, oStore.getState);
      }
      return next(oAction);
    };
  };
}

// Async action creator
loadCustomersAsync: function(oModel) {
  return function(dispatch, getState) {
    dispatch({ type: "CUSTOMERS_LOAD_START" });
    
    return new Promise(function(resolve, reject) {
      oModel.read("/Customers", {
        success: function(oData) {
          dispatch({
            type: "CUSTOMERS_LOAD_SUCCESS",
            payload: oData.results
          });
          resolve(oData);
        },
        error: function(oError) {
          dispatch({
            type: "CUSTOMERS_LOAD_ERROR",
            payload: oError.message
          });
          reject(oError);
        }
      });
    });
  };
}

// Usage
oStore.dispatch(customerActions.loadCustomersAsync(oModel))
  .then(function() {
    MessageToast.show("Loaded successfully");
  });

Selectors (Derived State)

// selectors/customerSelectors.js
sap.ui.define([], function() {
  "use strict";

  return {
    getCustomers: function(oState) {
      return oState.customers.customers || [];
    },

    getSelectedCustomer: function(oState) {
      return oState.customers.selectedCustomer;
    },

    getActiveCustomers: function(oState) {
      return this.getCustomers(oState).filter(function(c) {
        return c.Status === "Active";
      });
    },

    getCustomerById: function(oState, sId) {
      return this.getCustomers(oState).find(function(c) {
        return c.Id === sId;
      });
    },

    isLoading: function(oState) {
      return oState.customers.loading;
    },

    getError: function(oState) {
      return oState.customers.error;
    }
  };
});

// Usage in controller
import selectors from "../selectors/customerSelectors";

getActiveCustomers: function() {
  var oState = this._store.getState();
  return selectors.getActiveCustomers(oState);
}

State Persistence

LocalStorage Integration

// Save state to localStorage
saveState: function() {
  var oState = this._store.getState();
  var sState = JSON.stringify(oState);
  localStorage.setItem("appState", sState);
},

// Load state from localStorage
loadState: function() {
  var sState = localStorage.getItem("appState");
  if (sState) {
    try {
      return JSON.parse(sState);
    } catch (e) {
      console.error("Failed to load state", e);
      return undefined;
    }
  }
  return undefined;
},

// Initialize with persisted state
onInit: function() {
  var oInitialState = this.loadState();
  this._store = new Store(oInitialState);
  
  // Save on every state change
  this._store.subscribe(function() {
    this.saveState();
  }.bind(this));
}

Comparison Table

AspectJSON ModelOData ModelRedux Pattern
ComplexityLowMediumHigh
Setup TimeMinutesHoursDays
Best ForSmall apps, UI stateSAP backends, CRUDLarge apps, complex state
TestabilityMediumLowHigh
DebuggingBasicMediumExcellent
PerformanceFastBackend-dependentFast
Learning CurveEasyModerateSteep

Best Practices

✅ DO

  • Use JSON Model for UI-specific state
  • Use OData Model for backend data
  • Implement Redux pattern for complex apps (10+ views)
  • Keep state immutable in Redux reducers
  • Use selectors for derived state
  • Centralize state updates through actions
  • Persist critical state to localStorage
  • Document state structure clearly

❌ AVOID

  • Mixing state management approaches randomly
  • Direct state mutation in Redux
  • Storing everything in global state
  • Over-engineering simple apps with Redux
  • Ignoring OData model batch operations
  • Not handling loading and error states

Decision Guide

Use JSON Model When:

  • Building simple apps (1-5 views)
  • Managing UI state only
  • Rapid prototyping
  • No backend integration needed

Use OData Model When:

  • Working with SAP backends
  • Need automatic CRUD operations
  • Two-way binding to backend data
  • Standard Fiori applications

Use Redux Pattern When:

  • Building large applications (10+ views)
  • Complex state interactions
  • Need time-travel debugging
  • Multiple developers on same codebase
  • Strict testing requirements

Conclusion

State management in UI5 isn't one-size-fits-all. The right approach depends on your application'scomplexity, team size, and requirements.

Key takeaways:

  • Start simple with JSON Model
  • Use OData Model for SAP integration
  • Graduate to Redux for complex applications
  • Always consider testability and maintainability

Master state management, and you master UI5 application architecture.

About the Author: Yogesh Pandey is a passionate developer and consultant specializing in SAP technologies and full-stack development.