Skip to main content

TransferStateService

The TransferStateService allows you to transfer data from Scully’s static rendering process into the Angular application, eliminating redundant HTTP requests and providing instant content on page load.

Overview

This service works by:
  1. Capturing API responses during Scully’s build process
  2. Serializing and embedding them in the static HTML
  3. Rehydrating the data when the Angular app loads
  4. Providing cached data to components instead of making new API calls
This results in:
  • Faster initial page loads
  • Reduced API calls
  • Better user experience
  • Lower server costs

Installation

The service is automatically provided when you import ScullyLibModule with transfer state enabled:
import { ScullyLibModule } from '@scullyio/ng-lib';

@NgModule({
  imports: [
    ScullyLibModule.forRoot({
      useTransferState: true  // Enable transfer state
    })
  ]
})
export class AppModule { }

Methods

useScullyTransferState()

useScullyTransferState<T>(name: string, originalState: Observable<T>): Observable<T>
The primary method for wrapping observables with transfer state. This is the recommended approach. Parameters:
  • name: Unique identifier for this state
  • originalState: Your original observable (e.g., HTTP request)
Returns: Observable that uses cached state when available, falls back to original otherwise. How it works:
  • During Scully build: Executes originalState, stores result in state
  • On first client load: Returns cached state from HTML
  • On subsequent navigations: Returns cached state from prefetched data
  • On non-Scully pages: Executes originalState and stores result
Example:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { Observable } from 'rxjs';

interface BlogPost {
  id: number;
  title: string;
  content: string;
  author: string;
}

@Component({
  selector: 'app-blog-post',
  template: `
    <article *ngIf="post$ | async as post">
      <h1>{{ post.title }}</h1>
      <p class="author">by {{ post.author }}</p>
      <div [innerHTML]="post.content"></div>
    </article>
  `
})
export class BlogPostComponent implements OnInit {
  post$!: Observable<BlogPost>;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    const postId = 123; // Get from route params
    
    // Original observable (HTTP request)
    const httpRequest$ = this.http.get<BlogPost>(
      `https://api.example.com/posts/${postId}`
    );
    
    // Wrap with transfer state
    this.post$ = this.transferState.useScullyTransferState(
      `blog-post-${postId}`,
      httpRequest$
    );
  }
}

getState()

getState<T>(name: string): Observable<T>
Returns an observable that emits the state value and completes. Emits after navigation completes. Parameters:
  • name: The state key to retrieve
Returns: Observable that emits once with the state value. Example:
import { Component, OnInit } from '@angular/core';
import { TransferStateService } from '@scullyio/ng-lib';

interface UserData {
  name: string;
  email: string;
}

@Component({
  selector: 'app-user-profile',
  template: `
    <div *ngIf="user">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  `
})
export class UserProfileComponent implements OnInit {
  user?: UserData;

  constructor(private transferState: TransferStateService) {}

  ngOnInit() {
    this.transferState.getState<UserData>('user-data')
      .subscribe(user => {
        this.user = user;
      });
  }
}

setState()

setState<T>(name: string, val: T): void
Manually sets a value in the transfer state. Parameters:
  • name: The state key
  • val: The value to store
Example:
import { Component, OnInit } from '@angular/core';
import { TransferStateService } from '@scullyio/ng-lib';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-data-manager',
  template: `<div>{{ status }}</div>`
})
export class DataManagerComponent implements OnInit {
  status = 'Loading...';

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  async ngOnInit() {
    const data = await this.http.get('/api/data').toPromise();
    
    // Store in transfer state
    this.transferState.setState('app-data', data);
    
    this.status = 'Data loaded and cached';
  }
}

stateHasKey()

stateHasKey(name: string): boolean
Checks if a key exists in the current state (value may be undefined). Example:
import { Component } from '@angular/core';
import { TransferStateService } from '@scullyio/ng-lib';

@Component({
  selector: 'app-conditional-loader',
  template: `<div>{{ message }}</div>`
})
export class ConditionalLoaderComponent {
  message = '';

  constructor(private transferState: TransferStateService) {
    if (this.transferState.stateHasKey('cached-data')) {
      this.message = 'Using cached data';
    } else {
      this.message = 'Loading fresh data';
    }
  }
}

stateKeyHasValue()

stateKeyHasValue(name: string): boolean
Checks if a key exists and has a non-null/non-undefined value. Example:
import { Component, OnInit } from '@angular/core';
import { TransferStateService } from '@scullyio/ng-lib';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-smart-loader',
  template: `
    <div *ngIf="loading">Loading...</div>
    <div *ngIf="!loading">{{ data }}</div>
  `
})
export class SmartLoaderComponent implements OnInit {
  loading = true;
  data: any;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    if (this.transferState.stateKeyHasValue('my-data')) {
      // Use cached value
      this.transferState.getState('my-data').subscribe(data => {
        this.data = data;
        this.loading = false;
      });
    } else {
      // Fetch fresh data
      this.http.get('/api/data').subscribe(data => {
        this.data = data;
        this.loading = false;
        this.transferState.setState('my-data', data);
      });
    }
  }
}

Common Use Cases

Blog Post with API Data

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { Observable, switchMap } from 'rxjs';

interface Post {
  id: string;
  title: string;
  content: string;
  date: string;
  author: string;
}

@Component({
  selector: 'app-blog-post',
  template: `
    <article *ngIf="post$ | async as post">
      <header>
        <h1>{{ post.title }}</h1>
        <div class="meta">
          <time>{{ post.date | date }}</time>
          <span>by {{ post.author }}</span>
        </div>
      </header>
      <div class="content" [innerHTML]="post.content"></div>
    </article>
  `
})
export class BlogPostComponent implements OnInit {
  post$!: Observable<Post>;

  constructor(
    private route: ActivatedRoute,
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    this.post$ = this.route.params.pipe(
      switchMap(params => {
        const postId = params['id'];
        const httpRequest$ = this.http.get<Post>(
          `https://api.example.com/posts/${postId}`
        );
        
        return this.transferState.useScullyTransferState(
          `post-${postId}`,
          httpRequest$
        );
      })
    );
  }
}

Multiple API Calls

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { forkJoin, Observable } from 'rxjs';

interface User {
  id: string;
  name: string;
}

interface Post {
  id: string;
  title: string;
}

interface PageData {
  user: User;
  posts: Post[];
}

@Component({
  selector: 'app-user-dashboard',
  template: `
    <div *ngIf="pageData$ | async as data">
      <h1>{{ data.user.name }}'s Posts</h1>
      <article *ngFor="let post of data.posts">
        <h2>{{ post.title }}</h2>
      </article>
    </div>
  `
})
export class UserDashboardComponent implements OnInit {
  pageData$!: Observable<PageData>;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    const userId = '123';
    
    const user$ = this.transferState.useScullyTransferState(
      `user-${userId}`,
      this.http.get<User>(`/api/users/${userId}`)
    );
    
    const posts$ = this.transferState.useScullyTransferState(
      `user-posts-${userId}`,
      this.http.get<Post[]>(`/api/users/${userId}/posts`)
    );
    
    this.pageData$ = forkJoin({
      user: user$,
      posts: posts$
    });
  }
}

With Loading States

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { Observable, tap } from 'rxjs';

@Component({
  selector: 'app-products',
  template: `
    <div class="loading" *ngIf="loading">
      <p>Loading products...</p>
    </div>
    
    <div class="products" *ngIf="!loading">
      <div *ngFor="let product of products$ | async">
        {{ product.name }}
      </div>
    </div>
  `
})
export class ProductsComponent implements OnInit {
  products$!: Observable<any[]>;
  loading = true;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    const httpRequest$ = this.http.get<any[]>('/api/products');
    
    this.products$ = this.transferState
      .useScullyTransferState('products', httpRequest$)
      .pipe(
        tap(() => {
          this.loading = false;
        })
      );
  }
}

Service Integration

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { Observable } from 'rxjs';

export interface Article {
  id: string;
  title: string;
  content: string;
}

@Injectable({
  providedIn: 'root'
})
export class ArticleService {
  private apiUrl = 'https://api.example.com';

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  getArticle(id: string): Observable<Article> {
    const httpRequest$ = this.http.get<Article>(
      `${this.apiUrl}/articles/${id}`
    );
    
    return this.transferState.useScullyTransferState(
      `article-${id}`,
      httpRequest$
    );
  }
  
  getArticles(): Observable<Article[]> {
    const httpRequest$ = this.http.get<Article[]>(
      `${this.apiUrl}/articles`
    );
    
    return this.transferState.useScullyTransferState(
      'articles-list',
      httpRequest$
    );
  }
}

Configuration

Enable Transfer State

import { ScullyLibModule } from '@scullyio/ng-lib';

@NgModule({
  imports: [
    ScullyLibModule.forRoot({
      useTransferState: true  // Required for transfer state
    })
  ]
})
export class AppModule { }

Scully Configuration

Configure how state is stored:
// scully.config.ts
import { ScullyConfig } from '@scullyio/scully';

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'my-app',
  outDir: './dist/static',
  
  // Store state inline in HTML (default)
  inlineStateOnly: false,
  
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog'
      }
    }
  }
};
Storage Options:
  1. Inline in HTML (inlineStateOnly: true):
    • State embedded in the HTML file
    • Faster initial load
    • Larger HTML files
  2. Separate data.json (inlineStateOnly: false):
    • State in separate data.json file
    • Smaller HTML files
    • Requires additional HTTP request

How State is Stored

During the Scully build, state is embedded in the HTML:
<script id="ScullyIO-transfer-state">
  window['ScullyIO-transfer-state'] = {
    "blog-post-123": {
      "id": 123,
      "title": "My Blog Post",
      "content": "..."
    },
    "user-data": {
      "name": "John Doe",
      "email": "[email protected]"
    }
  };
</script>
Or in a separate file:
// dist/static/blog/my-post/data.json
{
  "blog-post-123": {
    "id": 123,
    "title": "My Blog Post",
    "content": "..."
  }
}

Advanced Usage

Custom State Management

import { Component, OnInit } from '@angular/core';
import { TransferStateService } from '@scullyio/ng-lib';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-custom-state',
  template: `<div>{{ data | json }}</div>`
})
export class CustomStateComponent implements OnInit {
  data: any;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  async ngOnInit() {
    const stateKey = 'custom-data';
    
    if (this.transferState.stateKeyHasValue(stateKey)) {
      // Use cached state
      this.transferState.getState(stateKey).subscribe(data => {
        this.data = data;
        console.log('Using cached state');
      });
    } else {
      // Fetch and cache
      const response = await this.http.get('/api/data').toPromise();
      this.data = response;
      this.transferState.setState(stateKey, response);
      console.log('Fetched and cached state');
    }
  }
}

Handling Errors

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferStateService } from '@scullyio/ng-lib';
import { catchError, of } from 'rxjs';

@Component({
  selector: 'app-error-handling',
  template: `
    <div *ngIf="data$ | async as data; else error">
      {{ data | json }}
    </div>
    <ng-template #error>
      <p>Failed to load data</p>
    </ng-template>
  `
})
export class ErrorHandlingComponent implements OnInit {
  data$: any;

  constructor(
    private http: HttpClient,
    private transferState: TransferStateService
  ) {}

  ngOnInit() {
    const httpRequest$ = this.http.get('/api/data').pipe(
      catchError(error => {
        console.error('API error:', error);
        return of(null); // Return fallback value
      })
    );
    
    this.data$ = this.transferState.useScullyTransferState(
      'data-with-fallback',
      httpRequest$
    );
  }
}

Troubleshooting

State Not Being Cached

Ensure transfer state is enabled:
ScullyLibModule.forRoot({ useTransferState: true })

State Not Loading

Check that you’re using useScullyTransferState() correctly:
// ✅ Correct
this.data$ = this.transferState.useScullyTransferState(
  'my-data',
  this.http.get('/api/data')
);

// ❌ Incorrect - Observable executes before wrapping
const data$ = this.http.get('/api/data');
this.data$ = this.transferState.useScullyTransferState('my-data', data$);

State Too Large

Reduce state size by:
  1. Only storing essential data
  2. Using pagination
  3. Splitting into multiple state keys

Source

View source on GitHub

Build docs developers (and LLMs) love