This tutorial was written by AI
The examples and explanations have been reviewed for accuracy. If you find an error, please let us know.
In this tutorial, you'll learn how to use Joystick's getter system to retrieve data from your database or external APIs. Getters are server-side functions that fetch data and make it available to your components.
What You'll Build
By the end of this tutorial, you'll have:
- Created a getter to fetch book data from a database
- Used the getter in a component to display the data
- Implemented input validation and authorization
- Added error handling and loading states
Prerequisites
Before starting, make sure you have:
- A Joystick app with a database configured (see Environment Settings)
- Basic understanding of Joystick components
Step 1: Create Your First Getter
Getters are defined in the /api
folder. Let's create a getter to fetch books from a database:
api/books/getters.js
const getters = {
books: {
get: () => {
return process.databases.mongodb.collection('books').find().toArray();
}
}
};
export default getters;
This basic getter:
- Defines a getter named
books
- Uses the
get()
method to define the retrieval logic - Accesses the MongoDB database via
process.databases.mongodb
- Returns all books from the
books
collection
Step 2: Register the Getter
To make your getter available, you need to register it in your API schema:
api/index.js
import book_getters from './books/getters.js';
const api = {
getters: {
...book_getters,
},
setters: {
// Your setters will go here
}
};
export default api;
Step 3: Use the Getter in a Component
Now let's create a component that uses our getter to display books. First, let's use the data()
function for server-side rendering:
ui/components/book_list/index.js
import joystick from '@joystick.js/ui';
const BookList = joystick.component({
data: async (api) => {
return {
books: await api.get('books')
};
},
css: `
.book-list {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.book-list h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.8rem;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.book-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.book-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.book-author {
color: #666;
margin-bottom: 0.5rem;
}
.book-year {
color: #888;
font-size: 0.9rem;
}
`,
render: ({ data, each }) => {
return `
<div class="book-list">
<h2>Our Book Collection</h2>
<div class="book-grid">
${each(data?.books, (book) => `
<div class="book-card">
<div class="book-title">${book.title}</div>
<div class="book-author">by ${book.author}</div>
<div class="book-year">${book.year}</div>
</div>
`)}
</div>
</div>
`;
},
});
export default BookList;
The data()
function:
- Runs on the server during server-side rendering
- Receives an
api
object with methods to call your getters - Returns data that becomes available as
data
in therender()
function
Step 4: Add Input Validation
Let's enhance our getter to accept input parameters and validate them:
api/books/getters.js
const getters = {
books: {
input: {
category: {
type: 'string',
required: false,
},
limit: {
type: 'integer',
required: false,
max: 100,
}
},
get: (input = {}) => {
const query = {};
if (input.category) {
query.category = input.category;
}
const limit = input.limit || 20;
return process.databases.mongodb
.collection('books')
.find(query)
.limit(limit)
.toArray();
}
}
};
export default getters;
Now update the component to use the input parameters:
ui/components/book_list/index.js
import joystick from '@joystick.js/ui';
const BookList = joystick.component({
data: async (api, req, input) => {
return {
books: await api.get('books', {
input: {
category: input?.category,
limit: input?.limit || 10
}
})
};
},
state: {
selectedCategory: 'all',
loading: false,
},
events: {
'change .category-select': async (event, instance) => {
const category = event.target.value;
instance.set_state({
selectedCategory: category,
loading: true
});
// Refetch data with new category
await instance.data.refetch({
category: category === 'all' ? null : category,
limit: 10
});
instance.set_state({ loading: false });
},
},
css: `
.book-list {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.book-list h2 {
color: #333;
margin-bottom: 1rem;
font-size: 1.8rem;
}
.filters {
margin-bottom: 2rem;
}
.category-select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.book-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.book-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.book-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.book-author {
color: #666;
margin-bottom: 0.5rem;
}
.book-category {
background: #e3f2fd;
color: #1976d2;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
display: inline-block;
margin-bottom: 0.5rem;
}
.book-year {
color: #888;
font-size: 0.9rem;
}
`,
render: ({ data, state, each, when }) => {
return `
<div class="book-list">
<h2>Our Book Collection</h2>
<div class="filters">
<select class="category-select">
<option value="all" ${state.selectedCategory === 'all' ? 'selected' : ''}>All Categories</option>
<option value="fiction" ${state.selectedCategory === 'fiction' ? 'selected' : ''}>Fiction</option>
<option value="non-fiction" ${state.selectedCategory === 'non-fiction' ? 'selected' : ''}>Non-Fiction</option>
<option value="science" ${state.selectedCategory === 'science' ? 'selected' : ''}>Science</option>
<option value="history" ${state.selectedCategory === 'history' ? 'selected' : ''}>History</option>
</select>
</div>
${when(state.loading, `
<div class="loading">Loading books...</div>
`)}
${when(!state.loading, `
<div class="book-grid">
${each(data?.books, (book) => `
<div class="book-card">
<div class="book-title">${book.title}</div>
<div class="book-author">by ${book.author}</div>
${book.category ? `<div class="book-category">${book.category}</div>` : ''}
<div class="book-year">${book.year}</div>
</div>
`)}
</div>
`)}
</div>
`;
},
});
export default BookList;
Step 5: Add Authorization
Let's add authorization to ensure only logged-in users can access the books:
api/books/getters.js
const getters = {
books: {
authorized: (input = {}, context = {}) => {
return !!context?.user;
},
input: {
category: {
type: 'string',
required: false,
},
limit: {
type: 'integer',
required: false,
max: 100,
}
},
get: (input = {}, context = {}) => {
const query = {};
if (input.category) {
query.category = input.category;
}
// Only show books available to this user's region
if (context.user?.region) {
query.availableRegions = { $in: [context.user.region] };
}
const limit = input.limit || 20;
return process.databases.mongodb
.collection('books')
.find(query)
.limit(limit)
.toArray();
}
}
};
export default getters;
Step 6: Call Getters Directly from Components
You can also call getters directly from component event handlers using the get()
method:
ui/components/book_search/index.js
import joystick, { get } from '@joystick.js/ui';
const BookSearch = joystick.component({
state: {
searchResults: [],
loading: false,
searchTerm: '',
},
events: {
'submit .search-form': async (event, instance) => {
event.preventDefault();
const searchTerm = event.target.search.value;
if (!searchTerm.trim()) return;
instance.set_state({
loading: true,
searchTerm
});
try {
const books = await get('books', {
input: {
category: 'fiction',
limit: 5
}
});
instance.set_state({
searchResults: books,
loading: false
});
} catch (error) {
console.error('Search failed:', error);
instance.set_state({
loading: false,
searchResults: []
});
}
},
},
css: `
.book-search {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
}
.search-form {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.search-input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.search-button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.search-button:hover {
background: #0056b3;
}
.search-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.search-results {
border-top: 1px solid #eee;
padding-top: 1rem;
}
.result-item {
padding: 1rem;
border-bottom: 1px solid #f0f0f0;
}
.result-title {
font-weight: 600;
color: #333;
}
.result-author {
color: #666;
margin-top: 0.25rem;
}
`,
render: ({ state, each, when }) => {
return `
<div class="book-search">
<form class="search-form">
<input
type="text"
name="search"
class="search-input"
placeholder="Search for books..."
value="${state.searchTerm}"
/>
<button
type="submit"
class="search-button"
${state.loading ? 'disabled' : ''}
>
${state.loading ? 'Searching...' : 'Search'}
</button>
</form>
${when(state.searchResults.length > 0, `
<div class="search-results">
<h3>Search Results:</h3>
${each(state.searchResults, (book) => `
<div class="result-item">
<div class="result-title">${book.title}</div>
<div class="result-author">by ${book.author}</div>
</div>
`)}
</div>
`)}
</div>
`;
},
});
export default BookSearch;
Step 7: Handle Errors Gracefully
Let's add proper error handling to our getter:
api/books/getters.js
const getters = {
books: {
authorized: (input = {}, context = {}) => {
return {
authorized: !!context?.user,
message: 'You must be logged in to view books.'
};
},
input: {
category: {
type: 'string',
required: false,
},
limit: {
type: 'integer',
required: false,
max: 100,
}
},
get: async (input = {}, context = {}) => {
try {
const query = {};
if (input.category) {
query.category = input.category;
}
if (context.user?.region) {
query.availableRegions = { $in: [context.user.region] };
}
const limit = Math.min(input.limit || 20, 100);
const books = await process.databases.mongodb
.collection('books')
.find(query)
.limit(limit)
.toArray();
return books;
} catch (error) {
console.error('Error fetching books:', error);
throw new Error('Failed to fetch books. Please try again later.');
}
}
}
};
export default getters;
Step 8: Test Your Getter
You can test your getter by:
- Starting your Joystick app:
joystick start
- Visiting the page that uses your BookList component
- Checking the browser's Network tab to see the API calls
- Testing different categories and limits
What You've Learned
Congratulations! You've learned how to:
- Create Getters: Define getter functions in the
/api
folder - Register Getters: Add getters to your API schema
- Use Data Function: Fetch data during server-side rendering
- Input Validation: Validate and sanitize input parameters
- Authorization: Protect getters with authorization logic
- Client-Side Calls: Call getters from component event handlers
- Error Handling: Handle errors gracefully in both getters and components
- Data Refetching: Update component data dynamically
Best Practices
- Keep getters focused: Each getter should have a single responsibility
- Validate input: Always validate and sanitize input parameters
- Use authorization: Protect sensitive data with proper authorization
- Handle errors: Provide meaningful error messages
- Limit data: Use pagination and limits to avoid performance issues
- Cache when appropriate: Consider caching for frequently accessed data
Next Steps
Now you can:
- Learn about using setters to change data
- Explore more complex database queries
- Implement search and filtering functionality
- Add pagination to your data lists
Getters are the foundation of data retrieval in Joystick. Master them, and you'll be able to build powerful, data-driven applications!