OpenCart Mobile App Developer Guide, Part 4: Design Patterns, Hooks and End-to-End Flows
Design patterns and React hooks used in the OpenCart mobile app, with end-to-end flows for login, cart, wishlist, checkout, online payment and orders.

This is part 4 of 5 of the OpenCart Mobile App Developer Guide. New to the project? Start with Part 1: Overview, Architecture, Repository Layout and Languages.
In this post
10. Design patterns
| Pattern | Where | Why |
|---|---|---|
| Backend for Frontend (BFF) | The mobile_api extension | Endpoints are shaped for the app’s screens (catalog.home returns all home sections in one call, productCard is a ready-to-render shape), so the app makes fewer, simpler calls. |
| Template Method / base class | ApiController | Shared steps (guard, envelope, decoding, price and image helpers) live in one place. Each endpoint only writes its own logic. |
| Guard clause | if (!$this->guard()) return; at the top of every method | Authentication can’t be forgotten in the middle of a method, and failure paths exit early. |
| Adapter / delegation to core | cart.add → api/cart.addProduct, totals → api/cart.getTotals, offline payments → extension/opencart/payment/<code>.confirm | Reuses OpenCart’s tested logic, so every installed shipping, payment and total extension works without changes. |
| Middleware / interceptor | Startup action (server); request() (client) | Cross-cutting concerns (session, currency, CORS, headers, token persistence, error mapping) handled once, outside the business code. |
| Uniform response envelope | ok() / fail() ↔ request() | One parser on the client. Field errors map straight onto form fields. |
| Server-authoritative state | Cart, checkout, prices | The client never computes prices or totals; it displays what the server returns. This prevents price tampering and keeps the app and website consistent. |
| State machine in the session | Checkout | Each step stores its result in the session (customer → addresses → shipping_method → payment_method → order_id). Changing an earlier step clears the later ones, so the order is always built from a valid sequence. |
| Repository / service module | api/endpoints.ts | One typed function per endpoint. Screens don’t know URLs. |
| Provider / Context | StoreProvider | App-wide session data without prop drilling. |
| Custom hooks | useCart, useAddToCart, useToggleWishlist, useStore | Encapsulate cache keys and update rules, so every screen updates the cart in the same way. |
| Cache-aside with write-through on mutation | useCartMutation → setQueryData | Mutations return fresh server data and put it straight into the cache. |
| Container / presentational split | Screens fetch; components such as ProductCard, Price and AddressForm mostly receive props | Components are reusable and easy to reason about. |
| Lazy module loading | require() of the navigator in App.tsx | Lets runtime theme values apply to module-level styles. |
| Typed contracts | types.ts, navigation/types.ts | Compile-time checking of the API and navigation contract. |
11. Hooks: which ones are used and why
11.1 React built-in hooks
| Hook | Used for | Example |
|---|---|---|
useState | Local UI state: form fields, selected options, quantity, field errors, search text, carousel index, WebView height | const [code, setCode] = useState('') in CartScreen |
useEffect | Syncing with something outside render: search debounce (setTimeout), filling the coupon field when the cart loads, loading init on mount | SearchScreen waits 400 ms after typing before setting the query term |
useLayoutEffect | Setting header buttons before the first paint so they don’t flicker | PaymentScreen’s “Cancel” header button |
useMemo | Stable derived values: the Context value object, the navigation theme | Stops every consumer of useStore() re-rendering on each parent render |
useCallback | Stable function identities in the Context (reload, login, logout, changeCurrency) | So the useMemo above stays stable |
useRef | A mutable flag that must not trigger a render | finished in PaymentScreen stops completing the order twice when several WebView events fire |
useContext | Reading StoreContext (wrapped by useStore) | n/a |
11.2 React Query hooks
| Hook | Used for | Why |
|---|---|---|
useQuery | Every read: home, product, cart, wishlist, orders, addresses, countries, checkout state | Caching, loading and error flags, refetch, enabled for dependent queries (e.g. shipping methods only after there is a shipping address) |
useInfiniteQuery | Product lists, reviews, orders | Page-based infinite scroll with getNextPageParam: last.page < last.pages ? last.page + 1 : undefined |
useMutation | Every write: add to cart, login, save address, confirm order… | isPending for button spinners, onSuccess to update the cache, onError to show errors |
useQueryClient | Manual cache work: setQueryData, invalidateQueries, removeQueries, clear | See the cache rules in §9.5 |
11.3 React Navigation hooks
| Hook | Used for |
|---|---|
useNavigation | Navigating from components that aren’t screens, or from tab screens to stack screens |
Screen props (route, navigation) | Reading typed parameters and calling navigate, replace, reset and setOptions |
11.4 Custom hooks in this project
| Hook | File | What it gives you |
|---|---|---|
useStore() | context/StoreContext.tsx | init, customer, isLoggedIn, loading, error, reload, login, logout, setCustomer, changeCurrency. Throws if used outside the provider. |
useCart() | hooks/useCart.ts | The cart query (['cart']) |
useCartCount() | hooks/useCart.ts | Number of items, for the tab badge |
useAddToCart(), useUpdateCartItem(), useRemoveCartItem(), useApplyCoupon() | hooks/useCart.ts | Cart mutations built on the private useCartMutation helper. Each writes the returned cart into the cache and invalidates checkout. |
useWishlist() | hooks/useWishlist.ts | The wishlist query (['wishlist']), which includes product_ids for quick lookups |
useToggleWishlist() | hooks/useWishlist.ts | isInWishlist(id), toggle(id), toggleAsync(id), isPending. Adds or removes depending on the current state and writes the result into the cache. |
Rule of thumb: if two or more screens read or change the same server data, put the query key and the update rule in a hook in src/hooks/. If only one screen uses it, an inline useQuery or useMutation is fine.
12. Flows, end to end
12.1 First launch and session creation
If the session expires on the server (System → Settings → Server → session lifetime), the server silently starts a new one. The response header carries the new id and client.ts stores it. The user then appears as a logged-out guest with an empty cart. This is the same as on the website.
12.2 Browsing
- Home calls
catalog.homeonce. It returns banners (from the banner chosen in the module settings), top-level categories, featured products (IDs from settings), latest, specials, and bestsellers (only if the core Bestseller module’s table exists). - Category → product list calls
product.indexwithcategory_id,sub_category=1,sort,pageandlimit.useInfiniteQueryloads the next page when you scroll near the end. - Search debounces typing by 400 ms, then calls
product.index&search=…. - Product calls
product.info. It returns images, price, special and quantity discounts (with tax applied according to store settings), options (variants inherit their master’s options), subscription plans, attributes, related products, review summary, stock text and the wishlist flag. - Prices are empty strings when the store requires login to see prices and nobody is logged in.
12.3 Authentication
Model: login is session-based, exactly like the website. There is no separate JWT. Logging in marks the server-side session (the one identified by X-Session-Token) as belonging to the customer. The token is stored in SecureStore, so the customer stays logged in across app restarts until the session expires or they log out.
- Cart merge: OpenCart’s cart table is keyed by session id and customer id. After login, core
Cartloads the customer’s saved cart together with the guest session’s cart, as on the website. - Register (
account.register) validates with the same rules and language strings as the website: name lengths, email format and uniqueness, telephone (if required), password length and complexity from store settings, and the account terms checkbox if a terms page is set. If the customer group requires approval, it returns{approval: true, customer: null}and the app shows a message instead of logging in. Otherwise it logs the customer in straight away. - Logout (
account.logout) calls$customer->logout()and removes the same session keys as the website’s logout. The app then callsqueryClient.clear(). - Forgotten password (
account.forgotten) creates a reset token through the core model, which triggers the core email. It returns the same message whether or not the email exists, so it can’t be used to find out which emails have accounts. - Change password (
account.password) requires the current password. - Login-gated screens: endpoints protected by
guardCustomer()return 401. The app shows a login prompt on the Account tab. Login and Register acceptredirect: 'Checkout'and return the user to checkout afterwards.
12.4 Cart
| Action | Endpoint | Server behaviour |
|---|---|---|
| Add | cart.add {product_id, quantity, option, subscription_plan_id} | Delegates to core api/cart.addProduct: checks the product exists and is enabled, validates required options, option values, variants, minimum quantity and subscription plan. Then resets checkout methods. |
| Edit quantity | cart.edit {key, quantity} | Updates the line; quantity 0 or less removes it |
| Remove | cart.remove {key} | n/a |
| Coupon | cart.coupon {coupon} | Validated by the core coupon total extension; an empty string removes it |
| View | cart.index | Lines (options, stock flag, minimum check, subscription text), totals from api/cart.getTotals, weight, warnings, can_checkout |
Each response is the full cart. The hooks write it into the cache, so the cart screen and the tab badge update without another request.
Option format: option is an object keyed by product_option_id. Select and radio options take one product_option_value_id. Checkbox options take an array of IDs. Text, textarea, date, time and datetime options take a string.
12.5 Wishlist
- Guest: product IDs are kept in
session['wishlist']. - Customer: stored in the database through
model_account_wishlist. - On login: the guest list is moved into the account.
- Every endpoint returns
{products, product_ids}.useToggleWishlistusesproduct_idsto decide between add and remove.
12.6 Addresses
address.save validates through validateAddress(), which applies the same rules as the website: postcode required per country, zone required if the country has zones. It creates or updates the address. The first address automatically becomes the default. If the checkout session points at an address that was edited, the session copy is refreshed. Delete refuses the default address, as the website does.
12.7 Checkout
The checkout follows the website’s steps and uses the same session keys, so every installed shipping, payment and order-total extension works unchanged.
Step by step:
- Customer. A logged-in customer’s details are put in
session['customer']automatically bystate(). A guest postscheckout.guest {firstname, lastname, email, telephone}. Guest checkout is only allowed when the store enables it, “login to see prices” is off, and the cart has no downloadable or subscription products (guestAllowed()), the same rule as the website. - Shipping address (only if the cart needs shipping). Either
{address_id}from the address book (logged in) or a full address (guest, or a new address with an optionalsave). Changing it clears the chosen shipping and payment methods, because quotes and taxes depend on the address. - Shipping method.
checkout.shippingMethodsasks every enabled shipping extension for quotes (model_checkout_shipping_method->getMethods) and stores the quotes in the session.checkout.shippingMethod {code: "flat.flat"}only accepts a code that exists in those stored quotes. The price comes from the stored quote, never from the client. - Payment address (only if System → Settings → Option → Billing address required is on).
- Payment method.
checkout.paymentMethodslists methods from enabled payment extensions for the payment (or shipping) address and stores them.checkout.paymentMethod {code}accepts only a stored code. - Confirm.
checkout.confirm {comment, agree}:requireCheckoutCustomer()andrequireCart()(products in the cart, in stock unless the store allows otherwise, minimum quantities met).validateConfirm(): every required step is done, the terms are accepted if a checkout terms page is set, and the shipping data is dropped if the cart no longer needs shipping.saveOrder()builds the order exactly likecatalog/controller/checkout/confirm.php: totals from the core total extensions, taxes, customer, addresses, methods, affiliate and marketing tracking, products with subscription data, IP and user agent. It saves withmodel_checkout_order->addOrder()and order status 0 (missing order). If an unpaid order from an earlier attempt exists in the session, it is updated witheditOrder()instead of creating a duplicate.- Offline payment (
cod,cheque,bank_transfer,free_checkoutfrom the coreopencartextension): runs that extension’s ownconfirm()controller, which sets the order status and sends emails. If it reports success (a redirect tocheckout/success),completeOrder()clears the cart and checkout session keys. The response is{status: "complete", order_id}. - Any other gateway: the response is
{status: "payment_required", payment_url, order_id}.
The app shows all steps on one CheckoutScreen. Each step’s mutation returns the new checkout state, which is written into ['checkout', 'state']. Shipping and payment method lists are separate queries whose keys include the address and method they depend on, so they refetch automatically when those change. They only run once their prerequisites are set (enabled).
12.8 Online payment (WebView)
- The success and failure URLs are matched with
/checkout[/%2F-]+success/i, so bothroute=checkout/successand SEO URLs work. - A
useRefflag makes sure the order is only finished once. - The Cancel button asks for confirmation. The unpaid order stays at status 0 and is reused on the next confirm.
- On Expo web, the payment page opens in a new tab and the user taps “I have paid”.
12.9 Orders and reorder
order.indexreturns the customer’s orders, paginated.order.inforeturns the details. Both usemodel_account_order, which only returns orders belonging to the logged-in customer.order.reorder {order_id, order_product_id}adds that line (with its options) back to the cart, if the product is still available.
12.10 Reviews
product.review mirrors the website’s validation: reviews enabled, guest reviews allowed or logged in, author 3–25 characters, text 25–1000 characters, rating 1–5. New reviews are saved with status 0, so they wait for admin approval as on the website.
12.11 Currency
The Account tab shows a currency picker when more than one currency is enabled. changeCurrency(code) saves the code to SecureStore, reloads init and invalidates every query. From then on, client.ts adds ¤cy=XXX to every request, and the startup puts it in the session.
12.12 Theme colours
The admin sets colours → common.init returns theme → App.tsx calls applyTheme() before loading the navigator → every screen’s styles use the store colours. A change takes effect on the next app launch. See §9.2 and §9.7.
← Previous: Part 3: The OpenCart Extension and the Mobile App
Next: Part 5: Configuration, Security, Extending, Testing, Releasing and Troubleshooting →
The series
- Part 1: Overview, Architecture, Repository Layout and Languages
- Part 2: Tools and Libraries, Why Expo, and the Development Environment
- Part 3: The OpenCart Extension and the Mobile App
- Part 4: Design Patterns, Hooks and End-to-End Flows (you are reading this part)
- Part 5: Configuration, Security, Extending, Testing, Releasing and Troubleshooting




